From c74a85786e02d0a49c0ac008bb75f788731d61dc Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 14:08:32 +0800 Subject: [PATCH 1/2] Invite and friend-chat polish: friend Bot placeholder from first paint, one invite control, Add friend auto-close, Sharing hint (#499) --- README.md | 4 +- README.zh.md | 4 +- .../499-invite-friendchat-polish.fixed.md | 1 + core/client.js | 61 ++++---- core/src/client/composer-label.js | 41 +++++ core/src/client/header.js | 30 +--- core/test/composer-label.test.js | 70 +++++++++ core/test/looks.test.js | 9 +- docs/subsystems/core-client.md | 2 +- docs/subsystems/social.md | 4 +- patches/ds-bot/0003-flybot-api-client.patch | 11 +- patches/ds-bot/0008-client-bundle.patch | 88 +++++------ patches/ds-bot/0012-room-membership.patch | 34 +---- .../ds-bot/files/src/client/composer-label.js | 41 +++++ .../ds-bot/files/test/composer-label.test.js | 70 +++++++++ plugins/social/client.js | 140 ++++++++++++------ plugins/social/test/social.test.js | 95 ++++++++++-- 17 files changed, 504 insertions(+), 201 deletions(-) create mode 100644 changelog.d/499-invite-friendchat-polish.fixed.md create mode 100644 core/src/client/composer-label.js create mode 100644 core/test/composer-label.test.js create mode 100644 patches/ds-bot/files/src/client/composer-label.js create mode 100644 patches/ds-bot/files/test/composer-label.test.js diff --git a/README.md b/README.md index 2dda9a19..857ade67 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu - **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). - **Members.** Access is a per-Bot **grant** (default deny, Chief included). A local Bot becomes `@.` on the server when you invite someone to that Bot, when it joins an invite, or when you bring it into a face-to-face room. The sidebar lists only Bots granted to you. **Manage access** on a Bot shows who has it and **Revoke**. Deleted Bots are revoked. Ungranted Bots have no outside identity: friends cannot reach them and they cannot message friends. - **Connectors (GitHub, Composio).** Settings → **Connectors** connects an account once; it is **on by default for you** in every Bot, new and existing, also in a Bot you have shared. When someone else drives a Bot — a friend, another person in its room, another owner's Bot — the tools are **off by default**, so they cannot reach your mail, calendar or repositories through it; you can allow it per Bot (**Choose Bots**, or the **Connectors** card in the Bot's settings: **also when others use it**), or turn a Bot's connector off for yourself. A room with other people in it counts as their use. Disconnecting resets every Bot's choices. -- **Friends in the Bot list.** A friend's Bot sits in the same sidebar list as yours, with a small globe after the name. Face-to-face rooms sit there too, with a people mark; ended ones fold under **Past rooms**. Click a row to open a normal chat. Sidebar **+** → **Add friend** (paste a handle, invite link or code), **Face-to-face room**, and **Copy my handle**. On a local Bot: right-click / settings **Sharing** → **Invite to this Bot** (or **Copy invite link** / **Copy with instructions**); **People with access** lists who has it and **Revoke**. Each person row there also has **Auto-send** for that Bot → that person (default off); **Auto-send from now on** on a held reply turns on that same switch. +- **Friends in the Bot list.** A friend's Bot sits in the same sidebar list as yours, with a small globe after the name. Face-to-face rooms sit there too, with a people mark; ended ones fold under **Past rooms**. Click a row to open a normal chat. Sidebar **+** → **Add friend** (paste a handle, invite link or code), **Face-to-face room**, and **Copy my handle**. On a local Bot: right-click / settings **Sharing** → **Copy invite link for **, then **Copy link** / **Copy with instructions** / **New link** (the same control as **Add friend** → **Invite a friend**); **People with access** lists who has it and **Revoke**. Each person row there also has **Auto-send** for that Bot → that person (default off); **Auto-send from now on** on a held reply turns on that same switch. - **Face-to-face rooms.** Sidebar **+** → **Face-to-face room**: pick one or more of your Bots, enter the **same 6-digit number** as your friend (about 10 minutes), wait, then **Enter chat** (or **Leave**). No prior friendship and no preview of the other home's other Bots. Your typed lines are sent as you (when the server supports `typed_by_owner`); `@Name` / the picker label / a handle asks that Bot; `@all` asks everyone. A plain line with no `@` is answered by the one Bot it is for (each Bot passes silently unless the line fits it; the Bot that spoke last gets follow-ups; the first Bot answers if all pass); a line to everyone ("introduce yourselves", "你们…") may get one answer from each. Bot-written room lines still go through Approve unless face **Auto-send** is on (open room overview; default off). - **Typed by the owner · mentions · thinking.** Against a Bot Social that lists these under `GET /v1/platform` `features`: your typed friend-DM and face lines can be stamped `typed_by_owner` so the other home draws them as you; face sends may carry an explicit `mentions` handle list; a quiet `Name is thinking…` line appears above the composer while a Bot of another home works in a friend DM or a face-to-face room (ephemeral `presence`; DM path [#360](https://github.com/ThinkFlowLab/FlyBot/pull/360) / [#332](https://github.com/ThinkFlowLab/FlyBot/issues/332)). Older servers simply omit what they do not support. - **Approvals.** Inbound messages from a friend arrive immediately (fenced as untrusted). Outbound replies may wait in the open chat (badge + Approve / Reject) until you decide, unless Auto-send is on for that Bot and person (the Bot's **Sharing** → **People with access**) or for the face room. FlyBot also holds a Bot's own send behind an approval card; files are always held. @@ -160,7 +160,7 @@ On first start FlyBot calls `POST /v1/onboard/flybot`, registers your ho **Change the server later** without editing YAML: Settings → **Bot Social** shows the server and its connection status, **Change server** checks the new address (`https://`, or `http://localhost` for development; FlyBot reads `/v1/platform` and shows its features) and, after a clear warning, switches without a restart. A new server is a **new identity**: your friends and sharing stay on the old server, your old credentials file is kept as a backup and never deleted, and switching back restores them. The `cordis.patch.yml` value stays the default. -**Share one Bot:** that Bot's settings → **Sharing** → **Invite to this Bot** (or **Copy invite link**); send the link. Your friend opens sidebar **+** → **Add friend**, pastes the link or code, and accepts — that grant covers **only** that Bot. **People with access** lists who has it; **Revoke** removes them. +**Share one Bot:** that Bot's settings → **Sharing** → **Copy invite link for ** (or **+** → **Add friend** → **Invite a friend**); send the link. Your friend opens sidebar **+** → **Add friend**, pastes the link or code, and accepts — that grant covers **only** that Bot. **People with access** lists who has it; **Revoke** removes them. **Chat:** click the friend's Bot in the sidebar. Their messages arrive at once. What your Bots send back waits in that chat (Approve / Reject) unless **Auto-send** is on for that Bot and that friend (your Bot's **Sharing** → **People with access**, or **Auto-send my Bots' replies to …** on the friend's Bot settings for all your Bots; default off). Look for the **to approve** badge if they see no reply. diff --git a/README.zh.md b/README.zh.md index 2f4f4105..f17f2ec4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -66,7 +66,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 - **成员。** 访问是按 Bot 的**授权**(默认拒绝,Chief 也一样)。本地 Bot 在你邀请别人使用它、它加入邀请、或你把它带进面对面房间时,才会在服务器上成为 `@.`。侧栏只列出别人授权给你的 Bot。每个 Bot 的 **管理访问** 列出谁有它,并可 **撤销**。删除 Bot 会撤销其身份。未授权的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 - **连接器(GitHub、Composio)。** 设置 → **连接器** 里连接一次账号;**默认对你自己开启**,每个 Bot(新建的和已有的)都能用,共享出去的 Bot 你自己也照样能用。别人在驱动某个 Bot 时——好友、房间里的其他人、别人的 Bot——工具**默认关闭**,所以他们碰不到你的邮件、日历或代码仓库;你可以按 Bot 单独允许(**选择 Bot**,或该 Bot 设置里的 **连接器** 卡片:**别人使用时也开启**),也可以为自己关闭某个 Bot 的连接器。有其他人在的房间里也算别人使用。断开连接会重置所有 Bot 的选择。 -- **侧栏里的好友。** 好友的 Bot 和你自己的 Bot 在同一列表,名字后面有一个小地球标记。面对面房间也在这份列表里,带一个小人标记;已结束的折在 **过去的房间** 下。点开就是普通对话。侧栏 **+** → **添加好友**(可粘贴句柄、邀请链接或码)、**面对面房间**、**复制我的 handle**。本地 Bot:设置里 **Sharing(分享)** → **Invite to this Bot(邀请使用此 Bot)**(或 **复制邀请链接** / 带说明的邀请);**People with access(访问权限)** 列出谁有它并可 **Revoke(撤销)**。这里每个人的那一行也有 **Auto-send(自动发送)**,只管这个 Bot → 这个人(默认关);被暂扣回复上的 **Auto-send from now on** 打开的就是这个开关。 +- **侧栏里的好友。** 好友的 Bot 和你自己的 Bot 在同一列表,名字后面有一个小地球标记。面对面房间也在这份列表里,带一个小人标记;已结束的折在 **过去的房间** 下。点开就是普通对话。侧栏 **+** → **添加好友**(可粘贴句柄、邀请链接或码)、**面对面房间**、**复制我的 handle**。本地 Bot:设置里 **Sharing(分享)** → **Copy invite link for (复制邀请链接)**,之后可 **Copy link** / **Copy with instructions**(带说明)/ **New link**(与 **添加好友** → **Invite a friend** 是同一个控件);**People with access(访问权限)** 列出谁有它并可 **Revoke(撤销)**。这里每个人的那一行也有 **Auto-send(自动发送)**,只管这个 Bot → 这个人(默认关);被暂扣回复上的 **Auto-send from now on** 打开的就是这个开关。 - **面对面房间。** 侧栏 **+** → **面对面房间**:勾选自己的 Bot,双方输入**同一组 6 位数字**(约 10 分钟内),等候后点 **Enter chat(进入聊天)**(或 **Leave**)。不必先成为好友,也不会暴露对方家里的其他 Bot。你打的字在对方看来是你本人(服务器支持 `typed_by_owner` 时);`@名字` / 选择器标签 / handle 点名该 Bot;`@all` 叫全员。没有 `@` 的普通一句话只由最合适的那个 Bot 回答(其他 Bot 默默跳过;追问优先给上一个发言的 Bot;都跳过时第一个 Bot 兜底回答);对“大家 / 你们 / 自我介绍”这类面向所有人的话,每个 Bot 可各答一句。Bot 自己写的房间消息仍可能进审批,除非在该房间概览开了面对面 **Auto-send**(默认关)。 - **主人手打 · 提及 · 正在输入。** 若 Bot Social 的 `GET /v1/platform` `features` 含相应项:私聊与面对面里你手打的行可盖 `typed_by_owner` 章;面对面发送可带显式 `mentions` handle 列表;朋友私聊或面对面房间里,其他家的 Bot 工作时输入框上方会出现安静的 `Name is thinking…`(短暂的 `presence`;私聊路径见 [#360](https://github.com/ThinkFlowLab/FlyBot/pull/360) / [#332](https://github.com/ThinkFlowLab/FlyBot/issues/332))。旧服务器没有的能力会自动跳过。 - **审批。** 好友发来的消息会立刻送达(按不可信内容处理)。你的 Bot 发出的回复可能停在当前聊天里等你批准(或拒绝),除非对这个 Bot 和这个人开了自动发送(该 Bot 的 **Sharing** → **People with access**),或该面对面房间开了自动发送。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 @@ -154,7 +154,7 @@ export PORT=8787 **之后换服务器**不用改 YAML:设置 → **Bot Social** 显示当前服务器和连接状态,**更换服务器**会先检查新地址(`https://`;开发时可用 `http://localhost`;FlyBot 读取 `/v1/platform` 并列出它的功能),经过明确的警告确认后无需重启即可切换。换服务器等于**换一个新身份**:好友和分享留在旧服务器上,旧的凭证文件作为备份保留、不会被删除,切换回去即可恢复。`cordis.patch.yml` 里的值仍是默认值。 -**分享某一个 Bot:** 该 Bot 设置 → **Sharing** → **Invite to this Bot**(或 **复制邀请链接**),把链接发给对方;对方侧栏 **+** → **添加好友** 粘贴链接或码并接受——**只授权这一个 Bot**。**People with access** 可查看并 **Revoke**。 +**分享某一个 Bot:** 该 Bot 设置 → **Sharing** → **Copy invite link for **(或 **+** → **添加好友** → **Invite a friend**),把链接发给对方;对方侧栏 **+** → **添加好友** 粘贴链接或码并接受——**只授权这一个 Bot**。**People with access** 可查看并 **Revoke**。 **私聊:** 点侧栏里对方的 Bot。对方来信立刻到。你这边发出去的内容停在该聊天等批准(除非对这个 Bot 和该好友开了 **Auto-send**:你的 Bot 的 **Sharing** → **People with access**;或在好友 Bot 设置里勾 **Auto-send my Bots' replies to …**,覆盖你所有 Bot;默认关)。对方看不到回复时,先看 **to approve / 待批准** 角标。 diff --git a/changelog.d/499-invite-friendchat-polish.fixed.md b/changelog.d/499-invite-friendchat-polish.fixed.md new file mode 100644 index 00000000..0092c2f3 --- /dev/null +++ b/changelog.d/499-invite-friendchat-polish.fixed.md @@ -0,0 +1 @@ +Invites and friend chats are tidier. A friend's Bot chat always reads **Message Chief (Alice)** in the composer, from the first open and after every send, never the coding-agent copy. **Add friend** and a Bot's **Sharing** use one invite control: **Copy invite link for Chief** makes and copies the link, which then shows with **Copy link**, **Copy with instructions** and **New link**, and every copy says which Bot it is for (`Invite link for Chief copied.`). The Add friend paste box wraps, so a whole pasted link is readable; typing clears an old error, and after an accepted invite the panel shows the result for a moment, then closes and opens the new friend's Bot. The Sharing hint mentions Revoke only once someone has access ([#499](https://github.com/ThinkFlowLab/FlyBot/issues/499)). diff --git a/core/client.js b/core/client.js index a5051ae9..46a79f9f 100644 --- a/core/client.js +++ b/core/client.js @@ -7769,6 +7769,38 @@ var SIDEBAR_CSS = ` .bt-row-settings svg{width:15px;height:15px} `; +// src/client/composer-label.js +var ATTRIBUTES = ["aria-label", "data-placeholder"]; +function holdComposerLabel(doc, label) { + doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); + const shell = /* @__PURE__ */ new Map(); + const apply2 = () => { + for (const el of doc.querySelectorAll("[data-composer-input]")) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name); + if (current === label) continue; + if (!shell.has(el)) shell.set(el, {}); + shell.get(el)[name] = current; + el.setAttribute(name, label); + } + } + }; + apply2(); + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; + const watcher = typeof Observer === "function" ? new Observer(apply2) : null; + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); + return () => { + watcher?.disconnect(); + for (const [el, values] of shell) { + if (!el.isConnected) continue; + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name); + else el.setAttribute(name, value); + } + } + }; +} + // src/client/header.js var import_react13 = require("react"); var import_react_dom4 = require("react-dom"); @@ -7852,34 +7884,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe delete document.body.dataset.btFriendChat; }; }, [bot?.remote]); - (0, import_react13.useEffect)(() => { - document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); - const input = document.querySelector("[data-composer-input]"); - const priorLabel = input?.getAttribute("aria-label") ?? null; - const apply2 = () => { - const el = document.querySelector("[data-composer-input]"); - if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); - }; - apply2(); - const timer = setTimeout(apply2, 0); - let frame = 0; - const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { - if (!frame) frame = requestAnimationFrame(() => { - frame = 0; - apply2(); - }); - }) : null; - watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); - return () => { - clearTimeout(timer); - watcher?.disconnect(); - if (frame) cancelAnimationFrame(frame); - const el = document.querySelector("[data-composer-input]"); - if (!el) return; - if (priorLabel == null) el.removeAttribute("aria-label"); - else el.setAttribute("aria-label", priorLabel); - }; - }, [composerLabel]); + (0, import_react13.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); (0, import_react13.useEffect)(() => { actions.beginVisit(sessionId); return () => actions.endVisit(sessionId); diff --git a/core/src/client/composer-label.js b/core/src/client/composer-label.js new file mode 100644 index 00000000..2dc45b99 --- /dev/null +++ b/core/src/client/composer-label.js @@ -0,0 +1,41 @@ +// The composer's label in a FlyBot chat (#419, #499). The shell picks its placeholder by phase, +// before it knows whose chat this is: the new-session hero copy ("Describe what you want to +// build, …") on a first open and its coding copy ("Message or run a task, …") after a send. A +// FlyBot chat, a friend's Bot above all, always reads "Message ": the visible placeholder +// is --bt-placeholder (styles.js draws it over the shell's text), and the input's aria-label and +// data-placeholder are put back whenever the shell renders them again. The mutation callback +// rewrites them in the same task, so no frame paints the shell's copy. +const ATTRIBUTES = ['aria-label', 'data-placeholder'] + +// Holds `label` on every composer input in `doc` until the returned function is called, which +// stops watching and gives each input the shell's latest own value back. +export function holdComposerLabel(doc, label) { + doc.body.style.setProperty('--bt-placeholder', JSON.stringify(label)) + // The shell's own value per input and attribute, newest first, for the hand-back. + const shell = new Map() + const apply = () => { + for (const el of doc.querySelectorAll('[data-composer-input]')) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name) + if (current === label) continue + if (!shell.has(el)) shell.set(el, {}) + shell.get(el)[name] = current + el.setAttribute(name, label) + } + } + } + apply() + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver + const watcher = typeof Observer === 'function' ? new Observer(apply) : null + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }) + return () => { + watcher?.disconnect() + for (const [el, values] of shell) { + if (!el.isConnected) continue + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name) + else el.setAttribute(name, value) + } + } + } +} diff --git a/core/src/client/header.js b/core/src/client/header.js index 86f0b8c8..f0f38c56 100644 --- a/core/src/client/header.js +++ b/core/src/client/header.js @@ -7,6 +7,7 @@ import { ActivityIndicator } from './activity-row.js' import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header @@ -101,33 +102,8 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus document.body.dataset.btFriendChat = '' return () => { delete document.body.dataset.btFriendChat } }, [bot?.remote]) - useEffect(() => { - document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) - const input = document.querySelector('[data-composer-input]') - const priorLabel = input?.getAttribute('aria-label') ?? null - const apply = () => { - const el = document.querySelector('[data-composer-input]') - if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) - } - apply() - const timer = setTimeout(apply, 0) - // The shell re-renders its composer after a send and puts its own coding label back - // ("Message or run a task, / commands, …"); keep "Message " on it (#419). - let frame = 0 - const watcher = typeof MutationObserver === 'function' - ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) - : null - watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) - return () => { - clearTimeout(timer) - watcher?.disconnect() - if (frame) cancelAnimationFrame(frame) - const el = document.querySelector('[data-composer-input]') - if (!el) return - if (priorLabel == null) el.removeAttribute('aria-label') - else el.setAttribute('aria-label', priorLabel) - } - }, [composerLabel]) + // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). + useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) useEffect(() => { actions.beginVisit(sessionId) return () => actions.endVisit(sessionId) diff --git a/core/test/composer-label.test.js b/core/test/composer-label.test.js new file mode 100644 index 00000000..7fa3434b --- /dev/null +++ b/core/test/composer-label.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { holdComposerLabel } from '../src/client/composer-label.js' + +// jsdom comes from the DSH harness, like the dsh packages (test/harness-resolve.mjs sets DSH_HARNESS). +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') + +// The shell's own composer copy (dsh-client-ui-conversation: placeholder.hero / placeholder.default). +const HERO = 'Describe what you want to build, / commands, @ files or sessions' +const CODING = 'Message or run a task, / commands, @ files or sessions' +const CODING_WORDS = /build|commands|@ files|sessions|run a task/ + +// The composer the shell renders: the input carries the copy as aria-label and data-placeholder, +// and an aria-hidden placeholder element shows it (styles.js draws --bt-placeholder over it). +function composer(doc, copy) { + const el = doc.createElement('div') + el.setAttribute('data-composer-input', '') + el.setAttribute('role', 'textbox') + el.setAttribute('aria-label', copy) + el.setAttribute('data-placeholder', copy) + return el +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) +const shown = (doc) => { + const el = doc.querySelector('[data-composer-input]') + return { aria: el.getAttribute('aria-label'), placeholder: el.getAttribute('data-placeholder'), css: JSON.parse(doc.body.style.getPropertyValue('--bt-placeholder')) } +} + +test('#499: a friend\'s Bot chat reads "Message " from the first paint, before and after a send', async () => { + const { window } = new JSDOM('
') + const doc = window.document + const label = 'Message Chief (SelfA1233)' + // First open: the shell renders its new-session hero copy. + doc.querySelector('main').append(composer(doc, HERO)) + const release = holdComposerLabel(doc, label) + // Synchronously, as in the header's layout effect: nothing is painted with the hero copy. + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // A send: the shell re-renders the same input with its coding copy. + const input = doc.querySelector('[data-composer-input]') + input.setAttribute('aria-label', CODING) + input.setAttribute('data-placeholder', CODING) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // The shell mounts a fresh composer (phase change or reload of the view). + input.replaceWith(composer(doc, CODING)) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + for (const value of Object.values(shown(doc))) assert.doesNotMatch(value, CODING_WORDS) + // Leaving the chat hands the shell its own latest copy back and stops watching. + release() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), CODING) + doc.querySelector('[data-composer-input]').setAttribute('aria-label', HERO) + await settle() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), HERO) +}) + +test('#499: the next chat\'s label takes over without a flash of the previous one', async () => { + const { window } = new JSDOM('') + const doc = window.document + doc.body.append(composer(doc, HERO)) + const first = holdComposerLabel(doc, 'Message Writer') + first() + const second = holdComposerLabel(doc, 'Message Chief (SelfB1233)') + assert.equal(shown(doc).aria, 'Message Chief (SelfB1233)') + await settle() + assert.equal(shown(doc).placeholder, 'Message Chief (SelfB1233)') + second() +}) diff --git a/core/test/looks.test.js b/core/test/looks.test.js index db508154..ec8c0aac 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -61,10 +61,11 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se // #276: gear is on every wide Bot row (own and friends'), not only remotes. assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) - assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) - // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. - assert.match(text('header.js'), /new MutationObserver\(/) - assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) + // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders + // it (composer-label.test.js); a friend's Bot chat hides the stats footer. + assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) + assert.match(text('composer-label.js'), /new Observer\(apply\)/) + assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..061daead 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -83,7 +83,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). -- `HeaderPill` sets the composer placeholder CSS var and mirrors the localized **Message {name}** string on `[data-composer-input]`'s `aria-label`; cleanup restores the prior label when the header unmounts or the session changes (#166). The shell re-renders its composer after a send and puts its own coding label back, so a `MutationObserver` (rAF-throttled, `aria-label` and child changes) keeps **Message {name}** on it while the chat is open (#419). In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. +- `HeaderPill` holds the localized **Message {name}** label on the composer with `holdComposerLabel` (`src/client/composer-label.js`) in a layout effect, so it is in place before the first paint: it sets the placeholder CSS var and writes the label on every `[data-composer-input]`'s `aria-label` and `data-placeholder`; cleanup hands each input the shell's latest own value back when the header unmounts or the session changes (#166). The shell picks its copy by phase before it knows whose chat it is (the new-session hero copy on a first open, its coding copy after a send), so a `MutationObserver` (those two attributes and child changes) rewrites them in its own callback, before the next paint, while the chat is open (#419, #499); `test/composer-label.test.js` drives it on a jsdom composer. In a friend's Bot chat it also sets `body[data-bt-friend-chat]`, and CSS hides the shell's run-stats footer (`[data-composer-stats]`) there: the friend's Bot runs on its owner's home, so local turns/steps/tok/s are relay turns and always 0 (#419). `ContextCell` hides `Tool added` / `Tool removed` rows for the roster's `pluginTools` (set in `indexRoster` via `setPluginTools`, `text.js`) as it does for connector tools (#419). The poll re-reads `pluginTools` from every `state` answer (a plugin reload makes no revision), and `ContextCell` (injected with `face`) selects them from the roster so a row already drawn re-renders when they arrive. ### Face-room held bubble and overview diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2e838dda..e8756c61 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -90,7 +90,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - #191: a peer's leave reads `Bob left the room` (the person, from the member's owner name; the Bot label only when there is no owner name), and an ended face room shows a plain status line above the composer (`FaceRoomStatus` on `flybot.session.dock`, `data-flybot=face-room-end`: `Bob left the room. This room is closed.` / `You left this room.`). Joining by number opens the new room in the main pane at once, so the top bar never keeps the previous room's title. - #197: while that status line shows, the composer is hidden (a CSS rule on the composer stack that has `data-flybot=face-room-end`), so a left or closed room is read-only. The account-menu handle uses normal ink. User-facing text no longer names a Friends' Bots section: `relationship_exists` reads `You're already friends. Their Bot is back in your list.`, and a held message to a friend's Bot says it waits for approval in this chat and names the Auto-send setting. - My own handle (`@name.home`) shows with a **Copy** link at the top of the account menu (slot `flybot.account.header`, `MyHandle`) as well as in Add friend (#181). -- Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then **Copy invite link for {Bot}**: mint + clipboard, and the link stays in a read-only field below it), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). After **Accept invite** the panel stays open with the result until **Done**: `{person} shared {Bot} with you.`, or, when the join brought no Bot, `Joined {person}'s invite, but their Bot hasn't loaded yet…` (#343). **Talk through** stays on one line (nowrap label). Bot Sharing puts the invite link on its own full-width row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button (#230); before the first link there is only **Invite to this Bot**. The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. +- Sidebar **+** is a short action menu, not the To:/composer. Add friend has two labelled groups (#343): **Accept an invite** (the full-width paste box `Paste invite link or code`, which wraps so a whole pasted link shows and accepts on Enter (#499), **Talk through [Bot]** when there is more than one Bot, then **Accept invite**) and **Invite a friend** (a **Share [Bot]** picker with the whole Bot name when there is more than one, then the shared invite control below), with a `You are @your.home` line (#181) under the title and **Done** at the bottom (Escape also closes). Typing in the paste box clears a failed accept's error (#499). After **Accept invite** the result shows: `{person} shared {Bot} with you.`, and about 1.8 s later the panel closes on its own and opens the new friend's Bot when its chat is already listed (#499); when the join brought no Bot (`Joined {person}'s invite, but their Bot hasn't loaded yet…`) or sent a request, the panel stays open until **Done** (#343). **Talk through** stays on one line (nowrap label). Add friend and Bot Sharing draw one invite control (`inviteControls` in `client.js`, #499): before a link there is one button, **Copy invite link for {Bot}** (mint + clipboard); then the link sits on its own full-width read-only row above one action row: **Copy link** (the one primary button), **Copy with instructions**, and a quiet **New link** text button that makes a fresh link without copying it (#230). Every copy note names the Bot: `Invite link for {Bot} copied.` / `Invite for {Bot} copied with instructions.`. The Sharing hint is `Invite grants {Bot} only.` and adds `Revoke anytime in People with access.` only once People with access lists someone (`ManageAccess` reports its count through `onGrants`); the empty list says `Nobody has this Bot yet. Copy an invite link above and send it to a friend.` (#499). The **Sharing** heading uses the same 4px inset as the core settings headings. The social strip keeps the **N to approve** badge and opens that chat; the stacked jump list stays out of the column. Held `face_room_message` drafts show in that face chat with readable names (`Chief → the room`), never raw `bot_01…` or a `FLYBOT-FACE-HUMAN` envelope. ### Face-room human turns and UI polish (#166, #167) @@ -110,7 +110,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. -- The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. +- The composer **Message {name}** label is held by core `HeaderPill` through `composer-label.js` (placeholder CSS var plus `aria-label` and `data-placeholder` on `[data-composer-input]`, from the first paint, restored on cleanup; #419, #499); chat-header tok jargon stays in pristine DSH / ds-bot. ### Face-room poll identity (#122) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e02c40ed..e44ed75c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2577,7 +2577,7 @@ diff --git a/test/looks.test.js b/test/looks.test.js import { indexRoster } from '../src/client/sources.js' const ids = Array.from({ length: 3000 }, (_, index) => `session-${index.toString(16).padStart(8, '0')}-look`) -@@ -29,19 +29,103 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl +@@ -29,19 +29,104 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl assert.equal(photo.shape, defaultShape('chief')) }) @@ -2617,10 +2617,11 @@ diff --git a/test/looks.test.js b/test/looks.test.js + // #276: gear is on every wide Bot row (own and friends'), not only remotes. + assert.match(text('sidebar.js'), /const settings = wide \? h\('button'/) + assert.doesNotMatch(text('sidebar.js'), /wide && bot\.remote \? h\('button'/) -+ assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/) -+ // #419: the composer keeps "Message " after the shell re-renders it; a friend's Bot chat hides the stats footer. -+ assert.match(text('header.js'), /new MutationObserver\(/) -+ assert.match(text('header.js'), /attributeFilter: \['aria-label'\]/) ++ // #419 / #499: the composer keeps "Message " from the first paint on and after the shell re-renders ++ // it (composer-label.test.js); a friend's Bot chat hides the stats footer. ++ assert.match(text('header.js'), /useLayoutEffect\(\(\) => holdComposerLabel\(document, composerLabel\), \[composerLabel\]\)/) ++ assert.match(text('composer-label.js'), /new Observer\(apply\)/) ++ assert.match(text('composer-label.js'), /const ATTRIBUTES = \['aria-label', 'data-placeholder'\]/) + assert.match(text('header.js'), /document\.body\.dataset\.btFriendChat = ''/) + assert.match(text('styles.js'), /body\[data-bt-friend-chat\] \[data-composer-stats\]\{display:none!important\}/) + assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..4bd45544 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2329,12 +2329,10 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); +@@ -2854,227 +2854,209 @@ function installCompiledStyles(text, tag) { style.remove(); -- }; --} + }; + } - -// src/client/mark.js -var import_react2 = require("react"); @@ -2419,8 +2417,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -3849,7 +3846,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +3942,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,12 +4369,12 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.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" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -5975,7 +5972,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5491,18 +5491,28 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +5491,60 @@ var SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -5991,6 +5988,38 @@ diff --git a/client.js b/client.js +.bt-row-settings svg{width:15px;height:15px} `; ++// src/client/composer-label.js ++var ATTRIBUTES = ["aria-label", "data-placeholder"]; ++function holdComposerLabel(doc, label) { ++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); ++ const shell = /* @__PURE__ */ new Map(); ++ const apply2 = () => { ++ for (const el of doc.querySelectorAll("[data-composer-input]")) { ++ for (const name of ATTRIBUTES) { ++ const current = el.getAttribute(name); ++ if (current === label) continue; ++ if (!shell.has(el)) shell.set(el, {}); ++ shell.get(el)[name] = current; ++ el.setAttribute(name, label); ++ } ++ } ++ }; ++ apply2(); ++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; ++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; ++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); ++ return () => { ++ watcher?.disconnect(); ++ for (const [el, values] of shell) { ++ if (!el.isConnected) continue; ++ for (const [name, value] of Object.entries(values)) { ++ if (value == null) el.removeAttribute(name); ++ else el.setAttribute(name, value); ++ } ++ } ++ }; ++} ++ // src/client/header.js -var import_react9 = require("react"); +var import_react13 = require("react"); @@ -6039,7 +6068,7 @@ diff --git a/client.js b/client.js 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,52 +5548,102 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +5548,75 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -6077,34 +6106,7 @@ diff --git a/client.js b/client.js + delete document.body.dataset.btFriendChat; + }; + }, [bot?.remote]); -+ (0, import_react13.useEffect)(() => { -+ document.body.style.setProperty("--bt-placeholder", JSON.stringify(composerLabel)); -+ const input = document.querySelector("[data-composer-input]"); -+ const priorLabel = input?.getAttribute("aria-label") ?? null; -+ const apply2 = () => { -+ const el = document.querySelector("[data-composer-input]"); -+ if (el && el.getAttribute("aria-label") !== composerLabel) el.setAttribute("aria-label", composerLabel); -+ }; -+ apply2(); -+ const timer = setTimeout(apply2, 0); -+ let frame = 0; -+ const watcher = typeof MutationObserver === "function" ? new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(() => { -+ frame = 0; -+ apply2(); -+ }); -+ }) : null; -+ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["aria-label"] }); -+ return () => { -+ clearTimeout(timer); -+ watcher?.disconnect(); -+ if (frame) cancelAnimationFrame(frame); -+ const el = document.querySelector("[data-composer-input]"); -+ if (!el) return; -+ if (priorLabel == null) el.removeAttribute("aria-label"); -+ else el.setAttribute("aria-label", priorLabel); -+ }; -+ }, [composerLabel]); ++ (0, import_react13.useLayoutEffect)(() => holdComposerLabel(document, composerLabel), [composerLabel]); + (0, import_react13.useEffect)(() => { actions.beginVisit(sessionId); return () => actions.endVisit(sessionId); diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 5c949605..5836487d 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -485,7 +485,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js diff --git a/src/client/header.js b/src/client/header.js --- a/src/client/header.js +++ b/src/client/header.js -@@ -4,8 +4,9 @@ import { currentPart, latestMemoryChange } from './text.js' +@@ -4,8 +4,10 @@ import { currentPart, latestMemoryChange } from './text.js' import { ArrowUpIcon, CloseIcon, ArrowDownIcon, MemoryIcon } from './icons.js' import { botState, BotAvatar, RoomAvatar } from './mark.js' import { ActivityIndicator } from './activity-row.js' @@ -493,10 +493,11 @@ diff --git a/src/client/header.js b/src/client/header.js +import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js' import { t } from './i18n.js' +import { remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' ++import { holdComposerLabel } from './composer-label.js' // ------------------------------------------------------------------------- // Conversation header -@@ -68,20 +68,72 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus +@@ -68,20 +68,47 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus const bot = roster.byId[sessionId] const room = roster.roomsById[sessionId] const detailsOpen = useUi(state => state.details === sessionId) @@ -535,33 +536,8 @@ diff --git a/src/client/header.js b/src/client/header.js + document.body.dataset.btFriendChat = '' + return () => { delete document.body.dataset.btFriendChat } + }, [bot?.remote]) -+ useEffect(() => { -+ document.body.style.setProperty('--bt-placeholder', JSON.stringify(composerLabel)) -+ const input = document.querySelector('[data-composer-input]') -+ const priorLabel = input?.getAttribute('aria-label') ?? null -+ const apply = () => { -+ const el = document.querySelector('[data-composer-input]') -+ if (el && el.getAttribute('aria-label') !== composerLabel) el.setAttribute('aria-label', composerLabel) -+ } -+ apply() -+ const timer = setTimeout(apply, 0) -+ // The shell re-renders its composer after a send and puts its own coding label back -+ // ("Message or run a task, / commands, …"); keep "Message " on it (#419). -+ let frame = 0 -+ const watcher = typeof MutationObserver === 'function' -+ ? new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; apply() }) }) -+ : null -+ watcher?.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label'] }) -+ return () => { -+ clearTimeout(timer) -+ watcher?.disconnect() -+ if (frame) cancelAnimationFrame(frame) -+ const el = document.querySelector('[data-composer-input]') -+ if (!el) return -+ if (priorLabel == null) el.removeAttribute('aria-label') -+ else el.setAttribute('aria-label', priorLabel) -+ } -+ }, [composerLabel]) ++ // Before the first paint, and again whenever the shell renders its own composer copy (#419, #499). ++ useLayoutEffect(() => holdComposerLabel(document, composerLabel), [composerLabel]) useEffect(() => { actions.beginVisit(sessionId) return () => actions.endVisit(sessionId) diff --git a/patches/ds-bot/files/src/client/composer-label.js b/patches/ds-bot/files/src/client/composer-label.js new file mode 100644 index 00000000..2dc45b99 --- /dev/null +++ b/patches/ds-bot/files/src/client/composer-label.js @@ -0,0 +1,41 @@ +// The composer's label in a FlyBot chat (#419, #499). The shell picks its placeholder by phase, +// before it knows whose chat this is: the new-session hero copy ("Describe what you want to +// build, …") on a first open and its coding copy ("Message or run a task, …") after a send. A +// FlyBot chat, a friend's Bot above all, always reads "Message ": the visible placeholder +// is --bt-placeholder (styles.js draws it over the shell's text), and the input's aria-label and +// data-placeholder are put back whenever the shell renders them again. The mutation callback +// rewrites them in the same task, so no frame paints the shell's copy. +const ATTRIBUTES = ['aria-label', 'data-placeholder'] + +// Holds `label` on every composer input in `doc` until the returned function is called, which +// stops watching and gives each input the shell's latest own value back. +export function holdComposerLabel(doc, label) { + doc.body.style.setProperty('--bt-placeholder', JSON.stringify(label)) + // The shell's own value per input and attribute, newest first, for the hand-back. + const shell = new Map() + const apply = () => { + for (const el of doc.querySelectorAll('[data-composer-input]')) { + for (const name of ATTRIBUTES) { + const current = el.getAttribute(name) + if (current === label) continue + if (!shell.has(el)) shell.set(el, {}) + shell.get(el)[name] = current + el.setAttribute(name, label) + } + } + } + apply() + const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver + const watcher = typeof Observer === 'function' ? new Observer(apply) : null + watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }) + return () => { + watcher?.disconnect() + for (const [el, values] of shell) { + if (!el.isConnected) continue + for (const [name, value] of Object.entries(values)) { + if (value == null) el.removeAttribute(name) + else el.setAttribute(name, value) + } + } + } +} diff --git a/patches/ds-bot/files/test/composer-label.test.js b/patches/ds-bot/files/test/composer-label.test.js new file mode 100644 index 00000000..7fa3434b --- /dev/null +++ b/patches/ds-bot/files/test/composer-label.test.js @@ -0,0 +1,70 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { holdComposerLabel } from '../src/client/composer-label.js' + +// jsdom comes from the DSH harness, like the dsh packages (test/harness-resolve.mjs sets DSH_HARNESS). +const { JSDOM } = createRequire(join(process.env.DSH_HARNESS, 'package.json'))('jsdom') + +// The shell's own composer copy (dsh-client-ui-conversation: placeholder.hero / placeholder.default). +const HERO = 'Describe what you want to build, / commands, @ files or sessions' +const CODING = 'Message or run a task, / commands, @ files or sessions' +const CODING_WORDS = /build|commands|@ files|sessions|run a task/ + +// The composer the shell renders: the input carries the copy as aria-label and data-placeholder, +// and an aria-hidden placeholder element shows it (styles.js draws --bt-placeholder over it). +function composer(doc, copy) { + const el = doc.createElement('div') + el.setAttribute('data-composer-input', '') + el.setAttribute('role', 'textbox') + el.setAttribute('aria-label', copy) + el.setAttribute('data-placeholder', copy) + return el +} +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) +const shown = (doc) => { + const el = doc.querySelector('[data-composer-input]') + return { aria: el.getAttribute('aria-label'), placeholder: el.getAttribute('data-placeholder'), css: JSON.parse(doc.body.style.getPropertyValue('--bt-placeholder')) } +} + +test('#499: a friend\'s Bot chat reads "Message " from the first paint, before and after a send', async () => { + const { window } = new JSDOM('
') + const doc = window.document + const label = 'Message Chief (SelfA1233)' + // First open: the shell renders its new-session hero copy. + doc.querySelector('main').append(composer(doc, HERO)) + const release = holdComposerLabel(doc, label) + // Synchronously, as in the header's layout effect: nothing is painted with the hero copy. + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // A send: the shell re-renders the same input with its coding copy. + const input = doc.querySelector('[data-composer-input]') + input.setAttribute('aria-label', CODING) + input.setAttribute('data-placeholder', CODING) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + // The shell mounts a fresh composer (phase change or reload of the view). + input.replaceWith(composer(doc, CODING)) + await settle() + assert.deepEqual(shown(doc), { aria: label, placeholder: label, css: label }) + for (const value of Object.values(shown(doc))) assert.doesNotMatch(value, CODING_WORDS) + // Leaving the chat hands the shell its own latest copy back and stops watching. + release() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), CODING) + doc.querySelector('[data-composer-input]').setAttribute('aria-label', HERO) + await settle() + assert.equal(doc.querySelector('[data-composer-input]').getAttribute('aria-label'), HERO) +}) + +test('#499: the next chat\'s label takes over without a flash of the previous one', async () => { + const { window } = new JSDOM('') + const doc = window.document + doc.body.append(composer(doc, HERO)) + const first = holdComposerLabel(doc, 'Message Writer') + first() + const second = holdComposerLabel(doc, 'Message Chief (SelfB1233)') + assert.equal(shown(doc).aria, 'Message Chief (SelfB1233)') + await settle() + assert.equal(shown(doc).placeholder, 'Message Chief (SelfB1233)') + second() +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index b206641d..2e0e7e44 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -123,6 +123,8 @@ window.__ModuleLoader__.load({ .fs-sharing{display:flex;flex-direction:column;gap:10px} .fs-label-plain{text-transform:none;letter-spacing:0;font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink,#141414)} .fs-invite-link{width:100%;box-sizing:border-box;min-width:0} +/* The paste box wraps, so a whole pasted link is readable at the sidebar's width (#499). */ +.fs-paste{flex:0 0 auto;width:100%;box-sizing:border-box;height:auto;min-height:30px;max-height:96px;padding:6px 9px;line-height:17px;resize:none;overflow-wrap:anywhere;word-break:break-all;field-sizing:content} .fs-invite-actions{flex-wrap:wrap} .fs-btn-fit{width:auto} .fs-share-request .fs-line{justify-content:flex-end} @@ -1654,7 +1656,7 @@ window.__ModuleLoader__.load({ error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, h(ShareRequests, { items: shareRequests, actions, onDismiss: dismissShareRequest }), h(FaceRooms, { actions, enabled: true, roster, remoteChats }), - adding ? h(AddFriend, { actions, onChange: loadApprovals, onDone: () => setAdding(false) }) : null, + adding ? h(AddFriend, { actions, remoteChats, onChange: loadApprovals, onDone: () => setAdding(false) }) : null, faceOpen ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null) } @@ -1682,8 +1684,36 @@ window.__ModuleLoader__.load({ })) } + // One invite control for Add friend and a Bot's Sharing (#499): one button makes the link and copies + // it; then the link shows with the same Copy link / Copy with instructions / New link, and every copy + // note names the Bot. A plain render helper without hooks: each caller keeps its own state. + const INVITE_COPY_FAILED = 'Could not copy. Select the link and copy it by hand.' + const inviteCopied = (botName, withText) => (withText ? `Invite for ${botName} copied with instructions.` : `Invite link for ${botName} copied.`) + // Call it straight from a click where possible, so the clipboard write keeps its permission. + const copyInvite = (text, botName, withText = false) => { + const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined + if (!text || !clip?.writeText) return Promise.resolve({ tone: 'error', text: INVITE_COPY_FAILED }) + return Promise.resolve(clip.writeText(text)).then(() => ({ tone: 'ok', text: inviteCopied(botName, withText) }), () => ({ tone: 'error', text: INVITE_COPY_FAILED })) + } + function inviteControls({ botName, invite, busy, onMint, onCopy, onNew } = {}) { + // The button names the Bot the link shares; a long name ends in an ellipsis instead of being clipped. + if (!invite) { + return h('div', { className: 'fs-line', 'data-flybot': 'invite-line' }, + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'mint-invite', title: `Copy invite link for ${botName}`, style: { flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, disabled: busy, onClick: onMint }, `Copy invite link for ${botName}`)) + } + // The link first, then Copy link as the one primary action; a new link is a quiet text button (#230). + return h(Fragment, null, + h('input', { className: 'fs-input fs-invite-link', readOnly: true, value: invite.link, 'aria-label': `Invite link for ${botName}`, 'data-flybot': 'invite-link', onFocus: e => e.target.select?.(), onClick: e => e.target.select?.() }), + h('div', { className: 'fs-line fs-invite-actions' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-primary fs-btn-fit', 'data-flybot': 'copy-link', onClick: () => onCopy(invite.link, false) }, 'Copy link'), + invite.text ? h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'copy-invite-text', onClick: () => onCopy(invite.text, true) }, 'Copy with instructions') : null, + h('button', { type: 'button', className: 'fs-link fs-invite-new', 'data-flybot': 'new-invite-link', disabled: busy, onClick: onNew }, 'New link'))) + } + + // How long an accepted invite's result shows before Add friend closes on its own (#499). + const ACCEPT_CLOSE_MS = 1800 // Add friend: accept a friend's invite, invite a friend to one of my Bots, and answer requests. - function AddFriend({ onChange, onDone } = {}) { + function AddFriend({ actions, remoteChats, onChange, onDone } = {}) { const [overview, setOverview] = useState(null) const [code, setCode] = useState('') const [status, setStatus] = useState(null) @@ -1696,6 +1726,10 @@ window.__ModuleLoader__.load({ const [me, setMe] = useState('') // The link that was just made, kept on screen so it can be checked or copied by hand (#343). const [made, setMade] = useState(null) + // The friend chats as of now, read when the panel closes after an accept; the pending close (#499). + const chats = useRef(remoteChats) + chats.current = remoteChats + const closing = useRef(null) // `keep`: a result is on screen (an accepted invite, say); a failed re-read of the friend list must not // replace it with an error that looks like the action failed (#343). const refresh = (keep = false) => call('social/overview', {}).then(setOverview, (e) => { if (!keep) setStatus({ tone: 'error', text: errorText(e) }) }) @@ -1717,6 +1751,7 @@ window.__ModuleLoader__.load({ document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [onDone]) + useEffect(() => () => clearTimeout(closing.current), []) const run = async (label, fn) => { setBusy(true) setStatus(null) @@ -1732,39 +1767,44 @@ window.__ModuleLoader__.load({ setBusy(false) } } - const mint = () => run('Invite', async () => { + // Makes a link for the picked Bot and copies it; New link (`copy` false) only replaces the link shown. + const mint = (copy = true) => run('Invite', async () => { const botId = sharing || mine.find(bot => bot.isMain)?.id || mine[0]?.id if (!botId) throw new Error('Choose one of your Bots to invite a friend to') const botName = mine.find(bot => bot.id === botId)?.name || 'your Bot' const invite = await call('social/create-invite', { botId }) const link = invite?.link || invite?.url if (!link) throw new Error('The invite came back without its link') - setMade({ link, botName }) - const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined - if (clip?.writeText) { - try { - await clip.writeText(link) - return `Invite link for ${botName} copied. Send it to a friend.` - } catch { - return `Could not copy. Copy the link below and send it to a friend.` - } - } - return 'Copy the link below and send it to a friend.' + setMade({ link, text: invite?.text, botName }) + return copy ? (await copyInvite(link, botName)).text : '' }) - const join = e => { + const copyMade = (text, withText) => { if (made) void copyInvite(text, made.botName, withText).then(setStatus) } + const join = async e => { e.preventDefault() - return run('Accepting invite', async () => { + if (busy || !code.trim()) return + let accepted + await run('Accepting invite', async () => { const r = await call('social/join-invite', { code, ...(joiner ? { botId: joiner } : {}) }) setCode('') const who = r.inviterName ?? r.inviter ?? 'Your friend' const shared = r.inviterBotName || r.sharedBot if (r.status === 'active') { - // The panel stays open with the result until Done, so it can be read (#343). A join that - // brought no Bot says so instead of claiming one was shared. + // A join that brought no Bot says so instead of claiming one was shared, and stays open. + if (shared) accepted = r return shared ? `${who} shared ${shared} with you.` : `Joined ${who}'s invite, but their Bot hasn't loaded yet. It shows in your list when it does; if not, accept the link again.` } return `Request sent to ${who}. They show up here once they accept.` }) + // The result shows for a moment, then the panel closes on its own and opens the new friend's + // Bot when it is already listed: no second click on Done (#499). + if (!accepted || !onDone) return + clearTimeout(closing.current) + closing.current = setTimeout(() => { + closing.current = null + const chat = chatByHandle(chats.current, accepted.inviter) + onDone() + if (chat?.id && typeof actions?.openSession === 'function') actions.openSession(chat.id) + }, ACCEPT_CLOSE_MS) } const respond = (item, decision) => run(decision === 'accept' ? 'Accepting' : 'Declining', async () => { await call('social/respond', { relationshipId: item.relationshipId, decision }) @@ -1781,8 +1821,13 @@ window.__ModuleLoader__.load({ me ? h('div', { className: 'fs-hint', 'data-flybot': 'my-handle', style: { padding: '0 10px' } }, `You are ${me}`) : null, h('div', { className: 'fs-label', 'data-flybot': 'accept-heading' }, 'Accept an invite'), h('form', { onSubmit: join, className: 'fs-card bt-social-join', 'data-flybot': 'accept-group' }, - // The paste box takes the full width on its own row, so the start of a link stays readable (#343). - h('input', { className: 'fs-input', style: { flex: '0 0 auto', width: '100%', boxSizing: 'border-box' }, value: code, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }), + // The paste box takes the full width on its own row and wraps, so a whole link is readable (#343, + // #499). Typing clears an error that no longer applies; Enter accepts, as in a one-line box. + h('textarea', { + className: 'fs-input fs-paste', rows: 2, style: { width: '100%' }, value: code, spellCheck: false, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', + onChange: (e) => { setCode(e.target.value); if (status?.tone === 'error') setStatus(null) }, + onKeyDown: (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent?.isComposing) { e.preventDefault(); e.currentTarget?.form?.requestSubmit?.() } }, + }), // Accepting grants only the invited Bot to you. The Bot you talk through is not shared, so // there is nothing to pick with one Bot (#228). mine.length > 1 ? h('label', { className: 'fs-line fs-hint fs-join-as', 'data-flybot': 'join-as', title: "Talks for you. Not shared: your friend can't use it unless you invite them to it." }, @@ -1799,10 +1844,8 @@ window.__ModuleLoader__.load({ h('span', { className: 'fs-join-as-label' }, 'Share '), h('select', { className: 'fs-input', value: sharing, title: shareName, 'aria-label': 'Which of your Bots to share', 'data-flybot': 'share-bot', onChange: e => { setSharing(e.target.value); setMade(null) } }, mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))) : null, - // The button names the Bot the link shares; a long name ends in an ellipsis instead of being clipped. - h('div', { className: 'fs-line', 'data-flybot': 'invite-line' }, - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'mint-invite', title: `Copy invite link for ${shareName}`, style: { flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, disabled: busy, onClick: mint }, `Copy invite link for ${shareName}`)), - made ? h('input', { className: 'fs-input', readOnly: true, value: made.link, style: { flex: '0 0 auto', width: '100%', boxSizing: 'border-box' }, 'aria-label': `Invite link for ${made.botName}`, 'data-flybot': 'invite-link', onFocus: e => e.target.select?.(), onClick: e => e.target.select?.() }) : null), + // The same invite control as the Bot's Sharing (#499). + inviteControls({ botName: made?.botName || shareName, invite: made, busy, onMint: () => mint(true), onCopy: copyMade, onNew: () => mint(false) })), status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone, style: { padding: '2px 10px' } }, status.text) : null, incoming.length ? h('div', { className: 'bt-social-requests' }, h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))), @@ -1851,7 +1894,7 @@ window.__ModuleLoader__.load({ const ACCESS_POLL_MS = 5000 const ACCESS_NOTE = 'One row per person. Revoke anyone without affecting the rest. Default: nobody, Chief included.' - function ManageAccess({ bot }) { + function ManageAccess({ bot, onGrants }) { const [view, setView] = useState(null) const [busy, setBusy] = useState('') const [error, setError] = useState('') @@ -1871,6 +1914,9 @@ window.__ModuleLoader__.load({ timer?.unref?.() return () => clearInterval(timer) }, [bot?.id]) + // Sharing's hint mentions Revoke only once someone has access (#499). + const granted = (view?.grants ?? []).length + useEffect(() => { onGrants?.(granted) }, [granted, onGrants]) if (!bot?.id) return null const revoke = async (grant) => { setBusy(grant.id) @@ -1896,7 +1942,7 @@ window.__ModuleLoader__.load({ return h('div', { className: 'fs fs-card', 'data-flybot': 'manage-access' }, h('div', { className: 'fs-name' }, 'People with access'), h('div', { className: 'fs-hint fs-face-note' }, ACCESS_NOTE), - grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Invite a friend to grant it.') : null, + grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Copy an invite link above and send it to a friend.') : null, grants.map(grant => h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' }, h('div', { className: 'fs-col' }, h('span', { className: 'fs-name' }, grant.name || grant.grantee), @@ -1995,38 +2041,40 @@ window.__ModuleLoader__.load({ return h('div', { 'data-flybot': 'bot-social-details' }, h(FriendChatSettings, { bot, actions })) } - // The host draws the Sharing title (#406). + return h(OwnBotSharing, { bot }) + } + + // The host draws the Sharing title (#406). People with access tells the invite hint whether + // anyone has this Bot yet, so the hint mentions Revoke only then (#499). + function OwnBotSharing({ bot } = {}) { + const [granted, setGranted] = useState(0) return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' }, - h(InviteToBot, { bot }), - h(ManageAccess, { bot })) + h(InviteToBot, { bot, revocable: granted > 0 }), + h(ManageAccess, { bot, onGrants: setGranted })) } - // In a Bot's settings: invite a friend to this Bot. - function InviteToBot({ bot }) { + // In a Bot's settings: invite a friend to this Bot, with the same control as Add friend (#499). + function InviteToBot({ bot, revocable = false } = {}) { useStyles() const [invite, setInvite] = useState(null) const [note, setNote] = useState(null) const [busy, setBusy] = useState(false) if (!bot?.id) return null - const create = async () => { + const create = async (copy) => { setBusy(true); setNote(null) - try { setInvite(await call('social/create-invite', { botId: bot.id })) } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } + try { + const next = await call('social/create-invite', { botId: bot.id }) + setInvite(next) + if (copy) setNote(await copyInvite(next?.link, bot.name)) + } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } } // Called straight from the click, so the clipboard write keeps its permission. - const copy = text => { - const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined - if (clip?.writeText) clip.writeText(text).then(() => setNote({ tone: 'ok', text: 'Copied.' }), () => setNote({ tone: 'error', text: 'Could not copy. Select the link and copy it by hand.' })) - } - // The link first, then Copy link as the one primary action; a new link is a quiet text button (#230). + const copy = (text, withText) => { void copyInvite(text, bot.name, withText).then(setNote) } + // Revoke is mentioned once People with access has someone to revoke (#499). return h('div', { className: 'fs fs-card', 'data-flybot': 'invite-to-bot' }, - h('div', { className: 'fs-hint' }, `Invite grants ${bot.name} only. Revoke anytime in People with access.`), - invite ? null : h('button', { type: 'button', className: 'fs-btn', disabled: busy, onClick: create }, 'Invite to this Bot'), - invite ? h('input', { className: 'fs-input fs-invite-link', readOnly: true, value: invite.link, 'aria-label': 'Invite link', onFocus: e => e.target.select() }) : null, - invite ? h('div', { className: 'fs-line fs-invite-actions' }, - h('button', { type: 'button', className: 'fs-btn fs-btn-primary fs-btn-fit', 'data-flybot': 'copy-link', onClick: () => copy(invite.link) }, 'Copy link'), - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'copy-invite-text', onClick: () => copy(invite.text) }, 'Copy with instructions'), - h('button', { type: 'button', className: 'fs-link fs-invite-new', 'data-flybot': 'new-invite-link', disabled: busy, onClick: create }, 'New link')) : null, - note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null) + h('div', { className: 'fs-hint', 'data-flybot': 'invite-hint-sharing' }, revocable ? `Invite grants ${bot.name} only. Revoke anytime in People with access.` : `Invite grants ${bot.name} only.`), + inviteControls({ botName: bot.name, invite, busy, onMint: () => create(true), onCopy: copy, onNew: () => create(false) }), + note ? h('div', { className: 'fs-status', role: 'status', 'data-tone': note.tone }, note.text) : null) } // A friend chat Bot Social paused (#404): a quiet notice, no buttons. There is no turn limit and diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 2b1dcc62..4e2be115 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -873,9 +873,9 @@ test('invite card: "Copy link" copies the bare URL; "Copy with instructions" the }) // A client.js component tree with a scripted useState order. -function clientPlugin(initial = [], { call = async () => ({}) } = {}) { +function clientPlugin(initial = [], { call = async () => ({}), timers = { setTimeout, clearTimeout }, navigator = {} } = {}) { const registered = [] - const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout, clearTimeout, console, navigator: {}, URL } + const sandbox = { window: { __ModuleLoader__: { load: r => registered.push(r) } }, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, console, navigator, URL } sandbox.globalThis = sandbox sandbox.__flybot__ = { slots: { register: () => () => {} }, call } runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) @@ -5715,24 +5715,94 @@ test('#297: social/typing answers from Bot Social only when the server lists pre assert.equal(older.calls.some(c => c.name === 'list_presence'), false, 'an older server is never asked') }) -test('#343: accepting an invite keeps the panel open with the result; a join without a Bot says so', async () => { +test('#343 / #499: an accepted invite shows its result, then the panel closes on the new friend\'s Bot; a join without a Bot stays open', async () => { const lastStatus = setters => setters.filter(([index, value]) => index === 2 && value !== null).at(-1)?.[1] - for (const [reply, expected] of [ - [{ status: 'active', inviterName: 'Alice', inviterBotName: 'Helper' }, /^Alice shared Helper with you\.$/], - [{ status: 'active', inviterName: 'Alice' }, /^Joined Alice's invite, but their Bot hasn't loaded yet/], - [{ status: 'pending_target', inviterName: 'Alice' }, /^Request sent to Alice/], + for (const [reply, expected, closes] of [ + [{ status: 'active', inviter: '@helper.alice.home', inviterName: 'Alice', inviterBotName: 'Helper' }, /^Alice shared Helper with you\.$/, true], + [{ status: 'active', inviterName: 'Alice' }, /^Joined Alice's invite, but their Bot hasn't loaded yet/, false], + [{ status: 'pending_target', inviterName: 'Alice' }, /^Request sent to Alice/, false], ]) { + const pending = [] + const timers = { setTimeout: (fn, ms) => { pending.push([fn, ms]); return pending.length }, clearTimeout() {} } const { plugin, all, setters } = clientPlugin([null, 'https://s/i/INV1', null, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', ''], { call: async endpoint => (endpoint === 'social/join-invite' ? reply : {}), + timers, }) - const notes = [] + const opened = [] let closed = 0 - const nodes = all(plugin.AddFriend({ actions: { notify: text => notes.push(text) }, onDone: () => { closed += 1 } })) + const remoteChats = [{ id: 'chat-helper', name: 'Helper', remote: { handle: '@helper.alice.home' } }] + const nodes = all(plugin.AddFriend({ actions: { openSession: id => opened.push(id) }, remoteChats, onDone: () => { closed += 1 } })) await nodes.find(n => n.type === 'form').props.onSubmit({ preventDefault() {} }) - await new Promise(resolve => setTimeout(resolve, 10)) assert.match(lastStatus(setters)?.text ?? '', expected) - assert.equal(closed, 0, 'the panel stays open until Done') + assert.equal(closed, 0, 'the result shows first') + const close = pending.filter(([, ms]) => ms === 1800) + assert.equal(close.length, closes ? 1 : 0, 'only an accept that shared a Bot closes on its own') + for (const [fn] of close) fn() + assert.equal(closed, closes ? 1 : 0, 'no second click on Done') + assert.deepEqual(opened, closes ? ['chat-helper'] : [], 'the new friend\'s Bot opens') + } +}) + +test('#499: typing in the paste box clears a failed accept\'s error, not a result; Enter accepts', () => { + const failed = { tone: 'error', text: 'Accepting invite failed: This invite has expired' } + const { plugin, all, setters } = clientPlugin([null, 'https://s/i/OLD', failed, false, [{ id: 'a', name: 'Chief', isMain: true }], 'a', 'a', '']) + const paste = all(plugin.AddFriend({})).find(n => n.props?.['aria-label'] === 'Invite link or code') + paste.props.onChange({ target: { value: 'https://s/i/NEW' } }) + assert.deepEqual(setters, [[1, 'https://s/i/NEW'], [2, null]]) + const ok = clientPlugin([null, '', { tone: 'ok', text: 'Alice shared Helper with you.' }, false, [], '', '', '']) + ok.all(ok.plugin.AddFriend({})).find(n => n.props?.['aria-label'] === 'Invite link or code').props.onChange({ target: { value: 'x' } }) + assert.deepEqual(ok.setters, [[1, 'x']], 'a result stays until the next action') + let submitted = 0 + let prevented = 0 + paste.props.onKeyDown({ key: 'Enter', shiftKey: false, preventDefault: () => { prevented += 1 }, currentTarget: { form: { requestSubmit: () => { submitted += 1 } } } }) + assert.equal(submitted, 1) + assert.equal(prevented, 1, 'Enter accepts instead of adding a line') +}) + +test('#499: Add friend and Sharing use one invite control, and every copy note names the Bot', async () => { + const copied = [] + const navigator = { clipboard: { writeText: async (text) => { copied.push(text) } } } + const invite = { link: 'https://s/i/INV7', text: 'Add my bot … Link: https://s/i/INV7 (single use)' } + const mine = [{ id: 'a', name: 'Chief', isMain: true }] + const added = clientPlugin([null, '', null, false, mine, 'a', 'a', '', { ...invite, botName: 'Chief' }], { navigator }) + const friend = added.all(added.all(added.plugin.AddFriend({})).find(n => n.props?.['data-flybot'] === 'invite-group')) + const sharing = clientPlugin([invite, null, false], { navigator }) + const settings = sharing.all(sharing.plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' } })) + const control = (nodes, text) => nodes.filter(n => n.type === 'input' || n.type === 'button').map(n => (n.type === 'input' ? `link:${n.props['aria-label']}:${n.props.value}` : `${n.props['data-flybot']}:${text(n)}`)) + const expected = ['link:Invite link for Chief:https://s/i/INV7', 'copy-link:Copy link', 'copy-invite-text:Copy with instructions', 'new-invite-link:New link'] + assert.deepEqual(control(friend, added.text), expected) + assert.deepEqual(control(settings, sharing.text), expected) + friend.find(n => n.props?.['data-flybot'] === 'copy-link').props.onClick() + settings.find(n => n.props?.['data-flybot'] === 'copy-link').props.onClick() + settings.find(n => n.props?.['data-flybot'] === 'copy-invite-text').props.onClick() + assert.deepEqual(copied, [invite.link, invite.link, invite.text], 'written inside each click') + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(added.setters.filter(([index]) => index === 2).map(([, value]) => value.text), ['Invite link for Chief copied.']) + assert.deepEqual(sharing.setters.filter(([index]) => index === 1).map(([, value]) => value.text), ['Invite link for Chief copied.', 'Invite for Chief copied with instructions.']) + // Before a link exists, both offer the same one button that makes and copies it. + const fresh = clientPlugin([null, null, false], { navigator, call: async () => invite }) + const first = fresh.all(fresh.plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' } })) + const mint = first.find(n => n.props?.['data-flybot'] === 'mint-invite') + assert.equal(fresh.text(mint), 'Copy invite link for Chief') + await mint.props.onClick() + assert.deepEqual(fresh.setters.filter(([index]) => index === 1).map(([, value]) => value?.text ?? null), [null, 'Invite link for Chief copied.']) + const blank = clientPlugin([null, '', null, false, mine, 'a', 'a', '']) + assert.equal(blank.text(blank.all(blank.plugin.AddFriend({})).find(n => n.props?.['data-flybot'] === 'mint-invite')), 'Copy invite link for Chief') +}) + +test('#499: the Sharing hint mentions Revoke only once People with access has someone', () => { + const hint = (revocable) => { + const { plugin, all, text } = clientPlugin([null, null, false]) + return text(all(plugin.InviteToBot({ bot: { id: 'a', name: 'Chief' }, revocable })).find(n => n.props?.['data-flybot'] === 'invite-hint-sharing')) } + assert.equal(hint(false), 'Invite grants Chief only.') + assert.equal(hint(true), 'Invite grants Chief only. Revoke anytime in People with access.') + const { plugin } = clientPlugin() + const own = plugin.BotSocialDetails({ bot: { id: 'a', name: 'Chief' } }) + const sharing = own.type(own.props) + const [invite, access] = sharing.props.children + assert.equal(invite.props.revocable, false, 'nobody has the Bot yet') + assert.equal(typeof access.props.onGrants, 'function', 'People with access reports how many have it') }) test('#343: a failed friend-list refresh does not replace the accepted-invite result; a long Bot name shrinks the button', async () => { @@ -5761,9 +5831,10 @@ test('#343: Add friend puts accepting first, on a full-width paste box, and name const form = nodes.findIndex(n => n.type === 'form') const invite = nodes.findIndex(n => n.props?.['data-flybot'] === 'invite-line') assert.ok(form > 0 && invite > form, 'the paste form comes before the invite line') - const paste = nodes.find(n => n.type === 'input' && n.props['aria-label'] === 'Invite link or code') + const paste = nodes.find(n => n.type === 'textarea' && n.props['aria-label'] === 'Invite link or code') assert.equal(paste.props.placeholder, 'Paste invite link or code') assert.equal(paste.props.style.width, '100%', 'the paste box has its own full-width row') + assert.match(paste.props.className, /\bfs-paste\b/, 'and wraps, so a whole pasted link shows (#499)') assert.equal(text(nodes.find(n => n.props?.['data-flybot'] === 'mint-invite')), 'Copy invite link for Chief') assert.equal(nodes.some(n => n.props?.['data-flybot'] === 'invite-link'), false, 'no link before one is made') }) From 6e7e9e6f1a24b49008d1daf201b13f2ae62be8f9 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 20 Oct 2026 15:00:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?Point=20leftover=20Invite=20to=20this=20Bot?= =?UTF-8?q?=20copy=20at=20Sharing=20=E2=86=92=20Copy=20invite=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #506. Host refusal, i18n/zh, new-chat fix note, tests, and docs/comments now match the #499/#504 Sharing button rename. Docs-not-needed: false (social.md / team.md updated) --- .../506-invite-copy-leftovers.fixed.md | 1 + core/client.js | 4 +- core/src/client/i18n.js | 2 +- core/src/client/new-chat.js | 2 +- core/src/host/hooks.js | 2 +- core/src/host/reach.js | 2 +- core/src/host/room-membership.js | 2 +- core/test/remote-room.test.js | 2 +- core/types/flybot.d.ts | 2 +- docs/subsystems/social.md | 6 +- docs/subsystems/team.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 2 +- patches/ds-bot/0008-client-bundle.patch | 583 +++++++++++------- patches/ds-bot/files/src/host/hooks.js | 2 +- patches/ds-bot/files/src/host/reach.js | 2 +- .../ds-bot/files/src/host/room-membership.js | 2 +- patches/ds-bot/files/test/remote-room.test.js | 2 +- patches/ds-bot/files/types/flybot.d.ts | 2 +- plugins/social/README.md | 2 +- plugins/social/src/social.js | 2 +- plugins/tree/README.md | 2 +- plugins/tree/src/adapter.js | 2 +- plugins/tree/src/middleware.js | 2 +- 24 files changed, 385 insertions(+), 249 deletions(-) create mode 100644 changelog.d/506-invite-copy-leftovers.fixed.md diff --git a/changelog.d/506-invite-copy-leftovers.fixed.md b/changelog.d/506-invite-copy-leftovers.fixed.md new file mode 100644 index 00000000..343aa01a --- /dev/null +++ b/changelog.d/506-invite-copy-leftovers.fixed.md @@ -0,0 +1 @@ +Host and zh copy that still said **Bot settings → Invite to this Bot** after #499/#504 now point at **Bot settings → Sharing → Copy invite link**. diff --git a/core/client.js b/core/client.js index 9d3acf42..558cbf1b 100644 --- a/core/client.js +++ b/core/client.js @@ -121,7 +121,7 @@ var ZH = { "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", "Invite link": "邀请链接", "Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.": "无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。", - "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", + "Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.": "无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。", "Add friend": "添加好友", "Friend requests": "好友请求", "Accept or decline incoming requests.": "接受或拒绝收到的请求。", @@ -11128,7 +11128,7 @@ function NewChat({ mode, roster, actions }) { if (!link) throw new Error("no link"); } catch { setInviteLink(""); - setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); + setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.")); return; } if (await copiedToClipboard(link)) { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 7832798d..8a756678 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -84,7 +84,7 @@ const ZH = { 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', 'Invite link': '邀请链接', 'Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.': '无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。', - 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', + 'Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.': '无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。', 'Add friend': '添加好友', 'Friend requests': '好友请求', 'Accept or decline incoming requests.': '接受或拒绝收到的请求。', diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 40ca4c12..b4629abd 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -74,7 +74,7 @@ export function NewChat({ mode, roster, actions }) { if (!link) throw new Error('no link') } catch { setInviteLink('') - setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) + setFixNote(t('Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.')) return } if (await copiedToClipboard(link)) { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 00ada390..a27cffed 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -946,7 +946,7 @@ export function install(rt) { list: () => team().map(view), find: name => view(rt.findBot?.(name)), isRemote, - // Whether this Bot has at least one person-grant (Invite to this Bot was accepted). + // Whether this Bot has at least one person-grant (Sharing → Copy invite link was accepted). hasGrant: (id, peer, roomId) => rt.hasGrant?.(id, peer, roomId) === true, hasAnyGrant: id => rt.hasAnyGrant?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), diff --git a/core/src/host/reach.js b/core/src/host/reach.js index 48acaa0d..041fe9b4 100644 --- a/core/src/host/reach.js +++ b/core/src/host/reach.js @@ -7,7 +7,7 @@ // // There is no global "published" flag. Every access is an independent per-Bot GRANT // (one of my Bots → one grantee; optionally scoped to a group). Default deny, Chief -// included. A grant is created when someone accepts "Invite to this Bot", or implicitly +// included. A grant is created when someone accepts a Sharing → Copy invite link, or implicitly // when the owner brings their own Bot into a face-to-face room by its 6-digit number // (`viaCode: true`, this room only, while a member). // diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 59d1a9ed..f3e585ef 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -485,7 +485,7 @@ export function install(rt) { const names = localBots.map(bot => bot.name).join(', ') const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' const many = localBots.length > 1 - const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Sharing → Copy invite link for ${names}), or leave ${many ? 'them' : 'it'} out.` return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 4922c9a8..aeb6f4cf 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -916,7 +916,7 @@ test('an ungranted local Bot refused next to a friend\'s Bot: the error names it await assert.rejects(async () => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA), (error) => { assert.equal(error.code, 'bot/forbidden') assert.match(error.message, /^Writer A is not shared with Bob, so it cannot be in a group with their Bot/) - assert.match(error.message, /Invite to this Bot/) + assert.match(error.message, /Sharing → Copy invite link for Writer A/) assert.match(error.message, /leave it out/) assert.deepEqual(error.details, { reason: 'ungranted-local', diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 9afd1868..ba4b5dbb 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -31,7 +31,7 @@ export interface BotView { name: string role?: string isMain: boolean - /** True when this Bot has at least one person-grant (Invite to this Bot was accepted). */ + /** True when this Bot has at least one person-grant (Sharing → Copy invite link was accepted). */ granted: boolean createdBy: BotId | null /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index e1c10ac8..669888fa 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. 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'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. 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'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -34,7 +34,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Sharing (Copy invite link) / Manage access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -45,7 +45,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. Runtime sources (`index.js`, `client.js`, `src/`) import only files in that package, Node builtins, or declared dependencies (`ds-bot`). They must not reach `core/` or another plugin by relative path: that path is missing after `plugin add …&path:/plugins/social` (#153). Tests may import from `core/` for fixtures. `scripts/lint-plugins.mjs` fails the leak and packs the plugin then imports it. 2. Uses documented plugin APIs only. 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. -4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Invite to this Bot** is the grant the other way; joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. +4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181). 6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social's hold names no DLP reason, the host approves it at once (`decide_approval`, the switch is the owner's consent), so the room sees it; a flagged line and other Bots' lines still wait for the owner. The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog shows "Nobody is using … yet." with **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) and **Start a room** (`start: true` adopts the held room through `GET`, no second create). A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line. 7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 7ebf0203..a7bcec49 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -72,7 +72,7 @@ Central authorization in `core/src/host/authz.js`, installed after member-auth: Typed participants on a native room, in `core/src/host/room-membership.js`, installed after groups: - A room may carry `participants`: `{ kind: 'human'|'bot', id, role: 'admin'|'member', displayName, joinedSeq, joinedAt, leftSeq?, leftAt?, invitedBy? }`. A **granted remote Bot** is the same kind with `remote: true`, `handle`, `ownerHome`, `ownerName`, `isChief?`. `room.members` stays the current *local* bot ids so existing single-owner groups keep their shape. Rooms without `participants` stay legacy: the owner is implicit and `who: 'User'` is that owner. -- Across owners, a *local* agent must have a **person-grant** (#90; Invite to this Bot was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. **Why the rule stands for a group (not only a 1:1 chat):** a group that holds a friend's Bot is projected to that friend's home. `fanoutRoomLine` sends its lines on, and the replies of every local Bot that may speak there; the friend's Bot's lines come back into the room that the local Bots read. A local Bot that is not shared with that friend would either be silently cut off from the friend (its lines are filtered, `mayShareRoom`) or be exposed to the friend's Bot's text — so the host refuses instead of half-including it, and the Bot Social server would refuse its messages anyway (no grant on its side). The refusal is not a dead end: it names the Bot and the friend (`details.reason: 'ungranted-local'`, `bots`, `remotes`, Bot names from local records, friend names through `cleanPeerName`) and the new-chat card offers **Copy invite link for {Bot}** (the Bot's own invite; the grant exists only after the friend accepts it) or **Leave {Bot} out**. Nothing is granted by the click, and no room-scoped grant is made for a plain group. +- Across owners, a *local* agent must have a **person-grant** (#90; Sharing → Copy invite link was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. **Why the rule stands for a group (not only a 1:1 chat):** a group that holds a friend's Bot is projected to that friend's home. `fanoutRoomLine` sends its lines on, and the replies of every local Bot that may speak there; the friend's Bot's lines come back into the room that the local Bots read. A local Bot that is not shared with that friend would either be silently cut off from the friend (its lines are filtered, `mayShareRoom`) or be exposed to the friend's Bot's text — so the host refuses instead of half-including it, and the Bot Social server would refuse its messages anyway (no grant on its side). The refusal is not a dead end: it names the Bot and the friend (`details.reason: 'ungranted-local'`, `bots`, `remotes`, Bot names from local records, friend names through `cleanPeerName`) and the new-chat card offers **Copy invite link for {Bot}** (the Bot's own invite; the grant exists only after the friend accepts it) or **Leave {Bot} out**. Nothing is granted by the click, and no room-scoped grant is made for a plain group. - Every posted line carries `authorId` + `authorKind` + a display snapshot (`who`). The signed-in actor wins; `body.authorId` grants nothing. Peer names are sanitized (`cleanPeerName` / `cleanRemote`). - **History-visibility rule (membership window):** only a *current* participant — or the owner of an invited Bot — may read a room. They see lines with `seq >=` the `joinedSeq` of their open interval (for an invited owner, their Bot's interval). Leaving closes the interval and withdraws all history access. Rejoining opens a new interval at the then-current seq; prior intervals do not come back. Non-members cannot read or inject. - Endpoints: `room-history`, `room-post`; `create-room` / `update-room` accept `participants` / `addParticipants` / `removeParticipants`. `view()` presents each viewer under the table below. Inbound projections (`origin: 'inbound-share'`, `rt.recordInboundSharedRoom` / `applyInboundRoomShare`) are the remote owner's copy of a friend's room that invited their granted Bot, created from a `FLYBOT-ROOM-SHARE` envelope when the Bot is added (not only after the first chat line). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad614280..f4f237cb 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -422,7 +422,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', + 'Invite link': '邀请链接', + 'Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.': '无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。', -+ 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', ++ 'Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.': '无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。', + 'Add friend': '添加好友', + 'Friend requests': '好友请求', + 'Accept or decline incoming requests.': '接受或拒绝收到的请求。', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 8f2aa7b5..24fed946 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1461,7 +1461,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + if (!link) throw new Error('no link') + } catch { + setInviteLink('') -+ setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) ++ setFixNote(t('Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.')) + return + } + if (await copiedToClipboard(link)) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 4bd45544..e7ff06db 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -64,7 +64,7 @@ diff --git a/client.js b/client.js + "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", + "Invite link": "邀请链接", + "Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.": "无法自动复制 {name} 的邀请链接。请手动复制下面的链接发给好友,对方接受后再重新建群。", -+ "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", ++ "Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.": "无法创建邀请。请在 Bot 设置 → Sharing 里使用 Copy invite link for this Bot。", + "Add friend": "添加好友", + "Friend requests": "好友请求", + "Accept or decline incoming requests.": "接受或拒绝收到的请求。", @@ -1030,6 +1030,7 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1066,7 +1067,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1092,30 +1107,35 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 - }; ++ }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { @@ -1128,13 +1148,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...state, [sessionId]: own }; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1145,10 +1159,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, +- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } +- } +-}; +-var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +-function themeVars(mode, dark, accent = mode.accent) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +-var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +-html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1194,15 +1291,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1235,13 +1327,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people2 = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1253,8 +1339,7 @@ diff --git a/client.js b/client.js + people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people2.length, 4), size); + const overflow = people2.length > 4 ? people2.length - 3 : 0; + const gap = size / 14; @@ -1273,97 +1358,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -- dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; --function themeVars(mode, dark, accent = mode.accent) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); --var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} --html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1550,7 +1553,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); - }; ++ }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1574,7 +1577,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } -+ }; + }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -1668,13 +1671,10 @@ diff --git a/client.js b/client.js // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1443,7 +1443,28 @@ function mentionSpans(text, bots, { references = [] } = {}) { - spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); - at = end - 1; +@@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { } -- return spans; -+ return spans; -+} + return spans; + } +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ @@ -1695,9 +1695,10 @@ diff --git a/client.js b/client.js + if (word) missing.push(`@${word}`); + } + return missing; - } ++} // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; @@ -1463,6 +1463,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; @@ -1786,11 +1787,35 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1573,60 +1573,246 @@ function relaysBefore(contexts, steps, step) { + break; + } } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { +- return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++ return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++} ++function commRunOf(order, peers, turn, hiddenTurns = []) { ++ const turns = []; ++ for (const entry of order) { ++ const peer = peers[entry]; ++ const hidden = hiddenTurns.includes(entry); ++ if (hidden && peer === void 0) continue; ++ turns.push({ turn: entry, peer, hidden }); ++ } ++ const at = turns.findIndex((entry) => entry.turn === turn); ++ if (at === -1 || turns[at].peer === void 0) return null; ++ const previous = turns[at - 1]; ++ if (previous?.peer !== void 0 && previous.hidden) return "folded"; ++ let count = 0; ++ const peerIds = []; ++ for (let index = at; index < turns.length; index += 1) { ++ const entry = turns[index]; ++ if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; ++ count += entry.hidden ? 2 : 1; ++ if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ } ++ return { count, peerIds }; ++} +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; @@ -1799,22 +1824,32 @@ diff --git a/client.js b/client.js +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); +function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; - for (let index = outline.length - 1; index >= 0; index -= 1) { - const entry = outline[index]; - if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; ++ if (!Array.isArray(outline)) return ""; ++ for (let index = outline.length - 1; index >= 0; index -= 1) { ++ const entry = outline[index]; ++ if (hiddenTurns.includes(entry.turn)) continue; + const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text !== "") return stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1624,9 +1624,188 @@ function usageOf(list, ids) { - const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); - } ++ } ++ return ""; ++} ++var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); ++function usageOf(list, ids) { ++ let input = 0; ++ let cacheRead = 0; ++ let output = 0; ++ for (const id of ids) { ++ const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; ++ if (!usage) continue; ++ input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); ++ cacheRead += usage.cacheReadTokens ?? 0; ++ output += usage.outputTokens ?? 0; ++ } ++ if (input + output === 0) return ""; ++ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; ++ return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++} +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); @@ -1874,7 +1909,26 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+} + } +-function commRunOf(order, peers, turn, hiddenTurns = []) { +- const turns = []; +- for (const entry of order) { +- const peer = peers[entry]; +- const hidden = hiddenTurns.includes(entry); +- if (hidden && peer === void 0) continue; +- turns.push({ turn: entry, peer, hidden }); +- } +- const at = turns.findIndex((entry) => entry.turn === turn); +- if (at === -1 || turns[at].peer === void 0) return null; +- const previous = turns[at - 1]; +- if (previous?.peer !== void 0 && previous.hidden) return "folded"; +- let count = 0; +- const peerIds = []; +- for (let index = at; index < turns.length; index += 1) { +- const entry = turns[index]; +- if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; +- count += entry.hidden ? 2 : 1; +- if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -1886,9 +1940,18 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return { count, peerIds }; + return { ...state, [sessionId]: own }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1912,7 +1975,8 @@ diff --git a/client.js b/client.js + label: "Moonlight", + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -+ } + } +- return ""; +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1935,7 +1999,18 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; -+} + } +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1983,7 +2058,10 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); -+ } + } +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -1992,7 +2070,7 @@ diff --git a/client.js b/client.js + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; -+} + } +var allThemes = () => ({ ...THEMES, ...customThemes }); // src/client/motion.js @@ -2329,10 +2407,21 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2854,227 +2854,209 @@ function installCompiledStyles(text, tag) { - style.remove(); +@@ -2843,238 +2843,220 @@ function installCompiledStyles(text, tag) { }; - } + const onLoad = (event) => { + if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; +-} - -// src/client/mark.js -var import_react2 = require("react"); @@ -2434,6 +2523,30 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; ++ }; ++ const observer = new MutationObserver(schedule); ++ observer.observe(document.head, { childList: true }); ++ document.addEventListener("load", onLoad, true); ++ return () => { ++ observer.disconnect(); ++ document.removeEventListener("load", onLoad, true); ++ clearTimeout(timer); ++ style.remove(); ++ }; + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); + +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -2466,19 +2579,13 @@ diff --git a/client.js b/client.js + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2488,7 +2595,9 @@ diff --git a/client.js b/client.js + if (!owner || seen.has(key)) continue; + seen.add(key); + out.push(owner); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2513,13 +2622,18 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2547,24 +2661,28 @@ diff --git a/client.js b/client.js + isChief: bot.remote.isChief + }); } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); ++} +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2594,25 +2712,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -4373,8 +4473,7 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -4387,7 +4486,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { @@ -9118,7 +9218,7 @@ diff --git a/client.js b/client.js + if (!link) throw new Error("no link"); + } catch { + setInviteLink(""); -+ setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); ++ setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot.")); + return; + } + if (await copiedToClipboard(link)) { @@ -9820,13 +9920,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react28.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react28.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react28.createElement)( + "div", @@ -13138,10 +13238,35 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -13052,11 +13052,27 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +13052,52 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random }; } ++// src/client/mobile.js ++var MOBILE_MAX_WIDTH = 640; ++var MOBILE_CSS = ` ++@media (max-width:${MOBILE_MAX_WIDTH}px){ ++.bt-held-actions{max-width:100%} ++.bt-held-card{padding:10px 12px} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600} ++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default} ++.bt-held-retry{min-height:32px} ++.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))} ++.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)} ++.bt-flybot-pointer{max-width:100%;min-width:0} ++.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none} ++.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis} ++.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px} ++.bt-inbox-card{flex-wrap:wrap;row-gap:8px} ++.bt-inbox-copy{flex-basis:calc(100% - 38px)} ++.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} ++.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px} ++.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px} ++} ++`; ++ +// src/client/friend-lines.js +function friendLineNews(bots, known, isOpen, selfId) { + const next = { ...known }; @@ -13464,7 +13589,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13344,6 +13344,18 @@ function apply(ctx) { +@@ -13344,13 +13344,25 @@ function apply(ctx) { } return { ...state, unread }; }); @@ -13483,6 +13608,14 @@ diff --git a/client.js b/client.js const refreshed = /* @__PURE__ */ new Set(); let pulls = 0; let applied = 0; + const pull = async () => { + const order = ++pulls; + const known = roster.getSnapshot(); +- const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language() }); ++ const value = await call("state", { since: known.revision, models: known.modelsKey ?? "", locale: language(), watching: !document.hidden }); + if (order < applied) return; + applied = order; + const nextActivity = JSON.stringify(value.activity ?? {}); @@ -13368,10 +13368,15 @@ function apply(ctx) { const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; @@ -13701,7 +13834,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,11 +14019,13 @@ function apply(ctx) { +@@ -14019,27 +14019,33 @@ function apply(ctx) { installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -13715,8 +13848,10 @@ diff --git a/client.js b/client.js + installCompiledStyles(FLYBOT_CSS, "flybot"), installCompiledStyles(MEMBER_CSS, "members"), installCompiledStyles(USER_MESSAGE_CSS, "user-messages"), ++ installCompiledStyles(MOBILE_CSS, "mobile"), browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null, -@@ -14032,14 +14032,17 @@ function apply(ctx) { + installTheme(), + shadow("sidebar.workspaces", { priority: -10, inject: face }, TeamSidebar), shadow("sidebar.brand.mark", { priority: -10 }, BrandMark), shadow("sidebar.brand.name", { priority: -10 }, BrandName), shadow("sidebar.footer.action", { id: "bot-account", order: -200, inject: face }, FooterAccount), diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 00ada390..a27cffed 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -946,7 +946,7 @@ export function install(rt) { list: () => team().map(view), find: name => view(rt.findBot?.(name)), isRemote, - // Whether this Bot has at least one person-grant (Invite to this Bot was accepted). + // Whether this Bot has at least one person-grant (Sharing → Copy invite link was accepted). hasGrant: (id, peer, roomId) => rt.hasGrant?.(id, peer, roomId) === true, hasAnyGrant: id => rt.hasAnyGrant?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), diff --git a/patches/ds-bot/files/src/host/reach.js b/patches/ds-bot/files/src/host/reach.js index 48acaa0d..041fe9b4 100644 --- a/patches/ds-bot/files/src/host/reach.js +++ b/patches/ds-bot/files/src/host/reach.js @@ -7,7 +7,7 @@ // // There is no global "published" flag. Every access is an independent per-Bot GRANT // (one of my Bots → one grantee; optionally scoped to a group). Default deny, Chief -// included. A grant is created when someone accepts "Invite to this Bot", or implicitly +// included. A grant is created when someone accepts a Sharing → Copy invite link, or implicitly // when the owner brings their own Bot into a face-to-face room by its 6-digit number // (`viaCode: true`, this room only, while a member). // diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 59d1a9ed..f3e585ef 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -485,7 +485,7 @@ export function install(rt) { const names = localBots.map(bot => bot.name).join(', ') const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' const many = localBots.length > 1 - const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Sharing → Copy invite link for ${names}), or leave ${many ? 'them' : 'it'} out.` return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 4922c9a8..aeb6f4cf 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -916,7 +916,7 @@ test('an ungranted local Bot refused next to a friend\'s Bot: the error names it await assert.rejects(async () => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA), (error) => { assert.equal(error.code, 'bot/forbidden') assert.match(error.message, /^Writer A is not shared with Bob, so it cannot be in a group with their Bot/) - assert.match(error.message, /Invite to this Bot/) + assert.match(error.message, /Sharing → Copy invite link for Writer A/) assert.match(error.message, /leave it out/) assert.deepEqual(error.details, { reason: 'ungranted-local', diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 9afd1868..ba4b5dbb 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -31,7 +31,7 @@ export interface BotView { name: string role?: string isMain: boolean - /** True when this Bot has at least one person-grant (Invite to this Bot was accepted). */ + /** True when this Bot has at least one person-grant (Sharing → Copy invite link was accepted). */ granted: boolean createdBy: BotId | null /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ diff --git a/plugins/social/README.md b/plugins/social/README.md index 5899a61b..50230e5d 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -18,7 +18,7 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root of the host's `Unknown FlyBot endpoint` message. The same failure is written to Settings → Feedback. - **Members**: a local Bot becomes `@.` (`add_home_member`) when it is invited, when it joins an invite, or at face-to-face join, stored as field `social.member`. There is no published - flag: access is a per-Bot **grant** (Invite to this Bot → accept). Default deny, Chief included. + flag: access is a per-Bot **grant** (Sharing → Copy invite link → accept). Default deny, Chief included. Deleted Bots are revoked on `bot.removed`. Ungranted Bots are not registered at start and are not polled. **Face-to-face join** of my own Bot is an implicit room-scoped grant: if it has no identity yet, the plugin registers it room-only (`social.member.roomOnly`). That identity does diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 206c5003..9a4e2d39 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -7,7 +7,7 @@ // - Every local Bot is a member identity @., keys custodied by the server. // A Bot is registered when it is invited, when it joins an invite, or at face-to-face // join (room-only). There is no global "published" flag: access is a per-Bot GRANT -// (Invite to this Bot → accept). Default deny, Chief included. Revoked on 'bot.removed'. +// (Sharing → Copy invite link → accept). Default deny, Chief included. Revoked on 'bot.removed'. // A face-to-face room-only identity is revoked when the Bot leaves its last face room. // - Friendship is Bot-to-Bot: an invite names one local Bot and the friend joins as one of // theirs; each side sees and reaches only the Bots granted to them (the server's diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 11c472db..f013b4eb 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -14,7 +14,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). - **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. No child is dropped. If that parent is now over `maxChildren`, it is regrouped by role automatically (#446), the same way as a full level, until it is within the limit. Each move is journaled and logged. - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. - Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one. -- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Invite to this Bot (default deny). +- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny). - Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. ### Config diff --git a/plugins/tree/src/adapter.js b/plugins/tree/src/adapter.js index 66164b14..c0b46560 100644 --- a/plugins/tree/src/adapter.js +++ b/plugins/tree/src/adapter.js @@ -10,7 +10,7 @@ // bot.send.use({ name, priority?, before?, after?, handle(msg) }) -> dispose (bands: bot.send.priority) // msg.target: { remote: false, id } | { remote: true, handle, home } // bot.isRemote(ref) -> boolean ({ kind: 'remote' } or '@handle'; local ids never) -// bot.hasAnyGrant(id) -> boolean (a person accepted Invite to this Bot; may be absent on an older core) +// bot.hasAnyGrant(id) -> boolean (a person accepted Sharing → Copy invite link; may be absent on an older core) // on('turn.start' | 'tool.start', fn) -> dispose (team activity feed: ids, times, tool names; no text) // api.handle(name, fn, { read? }) -> dispose (UI endpoint, called from the browser as flybot.call(name)) // tools.register({ name, description, parameters, visibleTo(botId) SYNC, execute(args, { botId }) }) diff --git a/plugins/tree/src/middleware.js b/plugins/tree/src/middleware.js index 98a6bb8f..bb13d997 100644 --- a/plugins/tree/src/middleware.js +++ b/plugins/tree/src/middleware.js @@ -7,7 +7,7 @@ // - A non-root tree Bot sending to a LOCAL Bot outside the tree is denied; the root (Chief, the // user's main bot) may message any local bot. // - A local non-tree sender may reach only the tree root; other tree nodes are denied. -// - Granted Bots on both sides (someone accepted Invite to this Bot) are not the tree's call +// - Granted Bots on both sides (someone accepted Sharing → Copy invite link) are not the tree's call // across trees or to non-tree Bots: undefined, and the core's owner policy decides who may // reach whom. Inside one tree the parent/child rule still holds. // - Non-tree -> non-tree traffic is not the tree's call: undefined.