diff --git a/changelog.d/2564-plugin-switches.added.md b/changelog.d/2564-plugin-switches.added.md
new file mode 100644
index 000000000..ba2219da9
--- /dev/null
+++ b/changelog.d/2564-plugin-switches.added.md
@@ -0,0 +1 @@
+Settings → Plugins can turn the Bot Social, Tree, Timeline and Workflow plugins off and back on ([#2564](https://github.com/ThinkFlowLab/FlyBot/issues/2564)). The switch is kept in FlyBot's own `state.json` and applies at the next start: a plugin that is off registers no tools, panels or endpoints, and the packages stay installed so nothing has to be reinstalled.
diff --git a/core/client.js b/core/client.js
index 3de5a5106..0bc3d0396 100644
--- a/core/client.js
+++ b/core/client.js
@@ -1300,6 +1300,12 @@ var ZH = {
"On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.": "默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。",
"No Bots": "没有 Bot",
"Scheduled tasks": "定时任务",
+ "A switch takes effect the next time FlyBot starts.": "开关在下次启动 FlyBot 时生效。",
+ "Restart FlyBot to apply the switches you changed.": "重启 FlyBot 后,你改动的开关才会生效。",
+ "Chat with a friend's Bots on a Bot Social server.": "在 Bot Social 服务器上与朋友的 Bot 聊天。",
+ "Run Bots as a tree under a coordinator.": "把 Bot 组织成树,由协调者带队。",
+ "Read the team activity as a timeline.": "用时间线查看团队活动。",
+ "Run workflow graphs across several Bots.": "跨多个 Bot 运行工作流图。",
"Scheduled tasks · {count}": "定时任务 · {count}",
"Every day": "每天",
"Every {count} days": "每 {count} 天",
@@ -1959,10 +1965,18 @@ function faceAliases(participant, name) {
// src/client/flybot-registry.js
var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
+var SLOT_OWNERS = [
+ ["social", /^social(?:$|-)/],
+ ["tree", /^flybot-tree(?:$|-)/],
+ ["timeline", /^timeline$/],
+ ["workflow", /^workflow$/]
+];
+var ownerOf = (id) => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? "")))?.[0];
function createRegistry() {
const entries2 = new Map(SLOTS.map((name) => [name, []]));
const listeners2 = /* @__PURE__ */ new Set();
let version = 0;
+ let disabled = /* @__PURE__ */ new Set();
const changed2 = () => {
version += 1;
for (const fn of [...listeners2]) fn();
@@ -1982,7 +1996,11 @@ function createRegistry() {
changed2();
};
},
- entries: (name) => entries2.get(name) ?? [],
+ entries: (name) => (entries2.get(name) ?? []).filter((entry) => !disabled.has(ownerOf(entry.id))),
+ setDisabledPlugins(ids = []) {
+ disabled = /* @__PURE__ */ new Set([...ids]);
+ changed2();
+ },
subscribe(fn) {
listeners2.add(fn);
return () => listeners2.delete(fn);
@@ -1995,6 +2013,10 @@ function registry() {
root.__flybot__ ??= { slots: createRegistry() };
return root.__flybot__.slots;
}
+function setDisabledPlugins(ids = []) {
+ registry().setDisabledPlugins(ids);
+}
+var pluginOfSlot = ownerOf;
function newChatActionRows(context = {}) {
const rows = [];
for (const entry of registry().entries("flybot.new-chat.action")) {
@@ -19285,8 +19307,8 @@ var ABOUT_CSS = `
// src/client/settings.js
var import_react42 = require("react");
var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
-var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
-var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
+var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["plugins", "Plugins"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
+var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
function SetSection({ title, children }) {
return (0, import_react42.createElement)("section", { className: "bt-set-section" }, (0, import_react42.createElement)("h3", null, title), (0, import_react42.createElement)("div", { className: "bt-set-card" }, children));
}
@@ -19303,6 +19325,46 @@ var NOTIFY_KINDS = [
["finished", "Finished"],
["failed", "Failed"]
];
+function PluginsPage({ call: call4 }) {
+ const [answer, setAnswer] = (0, import_react42.useState)(null);
+ const [error, setError] = (0, import_react42.useState)("");
+ (0, import_react42.useEffect)(() => {
+ let live = true;
+ call4("plugins/list").then((value) => {
+ if (live) setAnswer(value ?? { plugins: [] });
+ }).catch((failure) => {
+ if (live) setError(failure?.message ?? String(failure));
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+ const flip = (id, enabled) => {
+ call4("plugins/set", { id, enabled }).then((value) => {
+ setAnswer(value ?? null);
+ setError("");
+ }).catch((failure) => setError(failure?.message ?? String(failure)));
+ };
+ const plugins = answer?.plugins ?? [];
+ return (0, import_react42.createElement)(
+ "div",
+ null,
+ (0, import_react42.createElement)(
+ SetSection,
+ { title: t("Plugins") },
+ error ? (0, import_react42.createElement)("p", { className: "bt-set-hint", role: "alert" }, error) : null,
+ answer === null && !error ? (0, import_react42.createElement)("p", { className: "bt-set-hint" }, t("Loading…")) : null,
+ plugins.map((plugin) => (0, import_react42.createElement)(SwitchRow, {
+ key: plugin.id,
+ label: plugin.name,
+ hint: `${plugin.package} — ${t(plugin.purpose)}`,
+ on: plugin.enabled !== false,
+ onChange: (enabled) => flip(plugin.id, enabled)
+ })),
+ (0, import_react42.createElement)("p", { className: "bt-set-hint" }, answer?.pendingRestart === true ? t("Restart FlyBot to apply the switches you changed.") : t("A switch takes effect the next time FlyBot starts."))
+ )
+ );
+}
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {};
const themes = allThemes();
@@ -19618,6 +19680,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
shown === "workflows" ? (0, import_react42.createElement)(WorkflowsPage, { call: call2, roster }) : null,
pluginPage ? (0, import_react42.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
shown === "capabilities" ? (0, import_react42.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
+ shown === "plugins" ? (0, import_react42.createElement)(PluginsPage, { call: call2 }) : null,
shown === "schedules" ? (0, import_react42.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null,
@@ -22533,6 +22596,16 @@ function apply(ctx) {
}
ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy");
ctx.effect(() => installBrandChrome(), "ds-bot: brand chrome");
+ ctx.effect(() => {
+ let live2 = true;
+ call2("plugins/list").then((answer) => {
+ if (live2) setDisabledPlugins((answer?.plugins ?? []).filter((plugin) => plugin.enabled === false).map((plugin) => plugin.id));
+ }).catch(() => {
+ });
+ return () => {
+ live2 = false;
+ };
+ }, "ds-bot: plugin switches");
const roster = createSource(EMPTY_ROSTER);
ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === "undefined" ? void 0 : document), "ds-bot: tab title");
migrateStorage();
diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js
index f2e8d7de6..0979586a8 100644
--- a/core/src/client/flybot-registry.js
+++ b/core/src/client/flybot-registry.js
@@ -3,10 +3,25 @@
import { remoteMentionLabel } from '../shared/remote-label.js'
export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']
+// Which feature plugin a slot entry belongs to (#2564), read from the ids the four
+// plugin bundles register under. It lives here because a disabled plugin's bundle still
+// loads — the switch only stops its apply() on the host — so core is what hides the
+// panels it would have drawn, and one list is easier to keep true than five call sites.
+const SLOT_OWNERS = [
+ ['social', /^social(?:$|-)/],
+ ['tree', /^flybot-tree(?:$|-)/],
+ ['timeline', /^timeline$/],
+ ['workflow', /^workflow$/],
+]
+const ownerOf = id => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? '')))?.[0]
+
export function createRegistry() {
const entries = new Map(SLOTS.map(name => [name, []]))
const listeners = new Set()
let version = 0
+ // Feature plugins the owner switched off (#2564): their entries stay registered but
+ // are never handed out, so every surface that reads a slot drops them together.
+ let disabled = new Set()
const changed = () => {
version += 1
for (const fn of [...listeners]) fn()
@@ -26,7 +41,11 @@ export function createRegistry() {
changed()
}
},
- entries: name => entries.get(name) ?? [],
+ entries: name => (entries.get(name) ?? []).filter(entry => !disabled.has(ownerOf(entry.id))),
+ setDisabledPlugins(ids = []) {
+ disabled = new Set([...ids])
+ changed()
+ },
subscribe(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
@@ -41,6 +60,13 @@ export function registry() {
return root.__flybot__.slots
}
+// The live registry's switches, set from the host's answer at start and from Settings.
+export function setDisabledPlugins(ids = []) {
+ registry().setDisabledPlugins(ids)
+}
+
+export const pluginOfSlot = ownerOf
+
// Extra "@" rows from plugins (remote Bots and the like), after the team's own Bots.
// Extra rows in the sidebar '+' new-chat menu (after New Bot / New group chat).
export function newChatActionRows(context = {}) {
diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js
index 027688ce6..d833ea16e 100644
--- a/core/src/client/i18n.js
+++ b/core/src/client/i18n.js
@@ -1254,6 +1254,12 @@ const ZH = {
'On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend\'s own Bot never gets it.': '默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。',
'No Bots': '没有 Bot',
'Scheduled tasks': '定时任务',
+ 'A switch takes effect the next time FlyBot starts.': '开关在下次启动 FlyBot 时生效。',
+ 'Restart FlyBot to apply the switches you changed.': '重启 FlyBot 后,你改动的开关才会生效。',
+ 'Chat with a friend\'s Bots on a Bot Social server.': '在 Bot Social 服务器上与朋友的 Bot 聊天。',
+ 'Run Bots as a tree under a coordinator.': '把 Bot 组织成树,由协调者带队。',
+ 'Read the team activity as a timeline.': '用时间线查看团队活动。',
+ 'Run workflow graphs across several Bots.': '跨多个 Bot 运行工作流图。',
'Scheduled tasks · {count}': '定时任务 · {count}',
'Every day': '每天',
'Every {count} days': '每 {count} 天',
diff --git a/core/src/client/index.js b/core/src/client/index.js
index 510ffb502..0173e2649 100644
--- a/core/src/client/index.js
+++ b/core/src/client/index.js
@@ -1,6 +1,6 @@
-import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js'
+import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, call as flybotCall, createClientApi as createFlyBotClient } from './flybot-slots.js'
import { createElement as h } from 'react'
-import { registry as flybotSlots } from './flybot-registry.js'
+import { registry as flybotSlots, setDisabledPlugins } from './flybot-registry.js'
import { TURN_FAILURES_CSS, TurnFailures } from './turn-failures.js'
import { call } from './api.js'
import { isHex } from './inks.js'
@@ -65,6 +65,15 @@ export function apply(ctx) {
} catch { /* every provide form refused: __flybot__.api above still serves plugins */ }
ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy')
ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome')
+ // A plugin the owner switched off (#2564) still loads its browser bundle, so the
+ // switch is read once and its slot entries are dropped before anything draws them.
+ ctx.effect(() => {
+ let live = true
+ flybotCall('plugins/list')
+ .then(answer => { if (live) setDisabledPlugins((answer?.plugins ?? []).filter(plugin => plugin.enabled === false).map(plugin => plugin.id)) })
+ .catch(() => { /* an older host has no switch: every plugin stays visible */ })
+ return () => { live = false }
+ }, 'ds-bot: plugin switches')
const roster = createSource(EMPTY_ROSTER)
ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === 'undefined' ? undefined : document), 'ds-bot: tab title')
migrateStorage()
diff --git a/core/src/client/settings.js b/core/src/client/settings.js
index 0c7535578..8c9181276 100644
--- a/core/src/client/settings.js
+++ b/core/src/client/settings.js
@@ -28,8 +28,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1)
// Settings dialog: a 198px page list beside the page. Bots and group chats each list
// their members, and one opens on its own settings form. Usage widens the dialog.
-const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
-const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
+const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['plugins', 'Plugins'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
+const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
function SetSection({ title, children }) {
return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children))
@@ -46,6 +46,41 @@ const NOTIFY_KINDS = [
['finished', 'Finished'],
['failed', 'Failed'],
]
+// Settings → Plugins (#2564): the four feature plugins, each with one switch. The
+// switch writes FlyBot's state.json; the packages stay installed and the next start
+// reads the answer, so nothing unloads from the session that flipped it.
+function PluginsPage({ call }) {
+ const [answer, setAnswer] = useState(null)
+ const [error, setError] = useState('')
+ useEffect(() => {
+ let live = true
+ call('plugins/list')
+ .then(value => { if (live) setAnswer(value ?? { plugins: [] }) })
+ .catch(failure => { if (live) setError(failure?.message ?? String(failure)) })
+ return () => { live = false }
+ }, [])
+ const flip = (id, enabled) => {
+ call('plugins/set', { id, enabled })
+ .then(value => { setAnswer(value ?? null); setError('') })
+ .catch(failure => setError(failure?.message ?? String(failure)))
+ }
+ const plugins = answer?.plugins ?? []
+ return h('div', null,
+ h(SetSection, { title: t('Plugins') },
+ error ? h('p', { className: 'bt-set-hint', role: 'alert' }, error) : null,
+ answer === null && !error ? h('p', { className: 'bt-set-hint' }, t('Loading…')) : null,
+ plugins.map(plugin => h(SwitchRow, {
+ key: plugin.id,
+ label: plugin.name,
+ hint: `${plugin.package} — ${t(plugin.purpose)}`,
+ on: plugin.enabled !== false,
+ onChange: enabled => flip(plugin.id, enabled),
+ })),
+ h('p', { className: 'bt-set-hint' }, answer?.pendingRestart === true
+ ? t('Restart FlyBot to apply the switches you changed.')
+ : t('A switch takes effect the next time FlyBot starts.'))))
+}
+
// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393).
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {}
@@ -272,6 +307,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
shown === 'workflows' ? h(WorkflowsPage, { call, roster }) : null,
pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null,
shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null,
+ shown === 'plugins' ? h(PluginsPage, { call }) : null,
shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null,
shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
shown === 'feedback' ? h(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null,
diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js
index 92f448ca0..8a1b886b8 100644
--- a/core/src/host/hooks.js
+++ b/core/src/host/hooks.js
@@ -23,6 +23,7 @@ import { FEED_EVENTS, installFeed } from './flybot-feed.js'
import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { retiredBot } from './store.js'
+import { pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from './plugins.js'
import { codePoints } from './text.js'
import { cut } from '../shared/text-boundary.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
@@ -1102,6 +1103,17 @@ export function install(rt) {
},
}
const isReadEndpoint = name => READ_ENDPOINTS.has(name) || readHandlers.has(name)
+
+ // The owner's plugin switches (#2564). A flip only writes state.json: the plugins
+ // already loaded keep running until the next start, and `pendingRestart` says so.
+ api.handle('plugins/list', async () => ({ plugins: pluginSwitches(state), pendingRestart: rt.pluginSwitchPending === true }), { read: true })
+ api.handle('plugins/set', async (body = {}) => {
+ const before = pluginSwitches(state).find(plugin => plugin.id === body.id)
+ const plugins = setPluginSwitch(state, body.id, body.enabled)
+ if (before !== undefined && before.enabled !== body.enabled) rt.pluginSwitchPending = true
+ await rt.save()
+ return { plugins, pendingRestart: rt.pluginSwitchPending === true }
+ })
async function endpoint(name, payload, { actor } = {}) {
await rt.load()
const body = payload ?? {}
@@ -1143,6 +1155,10 @@ export function install(rt) {
const fields = fieldsFor(plugin)
return {
version: FLYBOT_API_VERSION,
+ // The owner's switch for a feature plugin (#2564): a plugin asks once, in its own
+ // apply(), and registers nothing when this answers false. Read from the state file
+ // the last run wrote, because plugins load before the team is loaded.
+ pluginEnabled: id => pluginEnabledAtStart(rt.statePath, id),
plugin,
scope: build,
bot: {
diff --git a/core/src/host/plugins.js b/core/src/host/plugins.js
new file mode 100644
index 000000000..7d2a249d4
--- /dev/null
+++ b/core/src/host/plugins.js
@@ -0,0 +1,51 @@
+// The four FlyBot feature plugins and the owner's switch for each (#2564).
+//
+// The switch does not install or uninstall anything: the packages stay in the DSH
+// profile as they are, and the answer lives in FlyBot's own state.json. A plugin reads
+// it once, in its own `apply()` at start, and returns before registering anything when
+// it is off — tools, slots, endpoints and prompt text all come from that call, so they
+// disappear together and come back together. That is why a flip applies at the next
+// start: nothing unloads from a running session.
+import { readFileSync } from 'node:fs'
+
+export const FEATURE_PLUGINS = Object.freeze([
+ { id: 'social', name: 'Bot Social', package: '@flybot/social', purpose: "Chat with a friend's Bots on a Bot Social server." },
+ { id: 'tree', name: 'Tree', package: '@flybot/tree', purpose: 'Run Bots as a tree under a coordinator.' },
+ { id: 'timeline', name: 'Timeline', package: '@flybot/timeline', purpose: 'Read the team activity as a timeline.' },
+ { id: 'workflow', name: 'Workflow', package: '@flybot/workflow', purpose: 'Run workflow graphs across several Bots.' },
+])
+
+export const FEATURE_PLUGIN_IDS = Object.freeze(FEATURE_PLUGINS.map(plugin => plugin.id))
+
+const known = id => FEATURE_PLUGIN_IDS.includes(id)
+
+// The four switches as the Settings page shows them: on unless state.json says off.
+export function pluginSwitches(state) {
+ const saved = state?.flybot?.plugins ?? {}
+ return FEATURE_PLUGINS.map(plugin => ({ ...plugin, enabled: saved[plugin.id]?.enabled !== false }))
+}
+
+// Writes one switch into the state the next save persists. Only these four ids are
+// accepted, so a typo cannot grow a fifth switch nobody reads.
+export function setPluginSwitch(state, id, enabled) {
+ const name = String(id ?? '')
+ if (!known(name)) throw new Error(`flybot: unknown plugin ${JSON.stringify(name)}`)
+ if (typeof enabled !== 'boolean') throw new Error('flybot: a plugin switch takes true or false')
+ state.flybot ??= {}
+ state.flybot.plugins ??= {}
+ state.flybot.plugins[name] = { enabled }
+ return pluginSwitches(state)
+}
+
+// Read at the plugin's own apply(), which runs before the host has loaded the team:
+// the file the last run wrote is the answer, and a missing or unreadable file means
+// "on", the same as a fresh install. An id this list does not know is left alone, so a
+// plugin added later without a switch here keeps loading.
+export function pluginEnabledAtStart(statePath, id) {
+ if (!known(id)) return true
+ try {
+ return JSON.parse(readFileSync(statePath, 'utf8'))?.flybot?.plugins?.[id]?.enabled !== false
+ } catch {
+ return true
+ }
+}
diff --git a/core/test/plugin-switches.test.js b/core/test/plugin-switches.test.js
new file mode 100644
index 000000000..25e4be14d
--- /dev/null
+++ b/core/test/plugin-switches.test.js
@@ -0,0 +1,109 @@
+// #2564: the owner's switch for the four feature plugins. The switch lives in FlyBot's
+// own state.json, is read at the plugin's apply() on start, and hides that plugin's slot
+// entries in the interface.
+import assert from 'node:assert/strict'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { createRuntime } from '../index.js'
+import { FEATURE_PLUGIN_IDS, pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from '../src/host/plugins.js'
+import { createRegistry, pluginOfSlot, registry, setDisabledPlugins } from '../src/client/flybot-registry.js'
+import { fakeContext } from './fake-ctx.js'
+
+const home = () => mkdtempSync(join(tmpdir(), 'dsb-plugins-'))
+const runtime = (state) => {
+ const dir = home()
+ if (state !== undefined) writeFileSync(join(dir, 'state.json'), JSON.stringify(state))
+ const ctx = fakeContext()
+ const rt = createRuntime(ctx, { home: dir, diagnosticsFile: false })
+ return { dir, rt, ctx }
+}
+
+test('every switch is on until state.json says otherwise', () => {
+ assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow'])
+ assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true])
+ assert.deepEqual(pluginSwitches({ flybot: { plugins: { workflow: { enabled: false } } } }).map(plugin => [plugin.id, plugin.enabled]), [
+ ['social', true], ['tree', true], ['timeline', true], ['workflow', false],
+ ])
+})
+
+test('a switch only takes the four known ids, and true/false only', () => {
+ const state = {}
+ assert.equal(setPluginSwitch(state, 'social', false).find(plugin => plugin.id === 'social').enabled, false)
+ assert.deepEqual(state.flybot.plugins.social, { enabled: false })
+ assert.throws(() => setPluginSwitch(state, 'core', false), /unknown plugin "core"/)
+ assert.throws(() => setPluginSwitch(state, 'tree', 'off'), /takes true or false/)
+})
+
+test('the host reads the switch from the file the last run wrote', () => {
+ const dir = home()
+ try {
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), true, 'no file means on')
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ flybot: { plugins: { social: { enabled: false } } } }))
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), false)
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'tree'), true)
+ writeFileSync(join(dir, 'state.json'), '{ not json')
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), true, 'an unreadable file means on')
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'not-a-plugin'), true, 'an unknown id keeps loading')
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+test('plugins/list answers all four switches and plugins/set persists a flip', async () => {
+ const { dir, rt } = runtime()
+ try {
+ const listed = await rt.flybotEndpoint('plugins/list')
+ assert.equal(listed.pendingRestart, false)
+ assert.deepEqual(listed.plugins.map(plugin => plugin.id), [...FEATURE_PLUGIN_IDS])
+ assert.equal(listed.plugins.find(plugin => plugin.id === 'workflow').package, '@flybot/workflow')
+
+ const saved = await rt.flybotEndpoint('plugins/set', { id: 'workflow', enabled: false })
+ assert.equal(saved.pendingRestart, true)
+ assert.equal(saved.plugins.find(plugin => plugin.id === 'workflow').enabled, false)
+ await rt.save()
+ const onDisk = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'))
+ assert.deepEqual(onDisk.flybot.plugins.workflow, { enabled: false })
+ // The running process keeps what it loaded; only the next start reads this.
+ assert.equal(rt.flybot.pluginEnabled('workflow'), false)
+ assert.equal(rt.flybot.pluginEnabled('social'), true)
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+test('a disabled plugin keeps no slot entry visible, and the other plugins stay', () => {
+ const slots = createRegistry()
+ slots.register('flybot.sidebar.section', { id: 'social' })
+ slots.register('flybot.sidebar.section', { id: 'workflow' })
+ slots.register('flybot.sidebar.section', { id: 'flybot-tree' })
+ slots.register('flybot.settings.page', { id: 'timeline' })
+ slots.register('flybot.sidebar.section', { id: 'core-thing' })
+ assert.deepEqual(slots.entries('flybot.sidebar.section').map(entry => entry.id), ['social', 'workflow', 'flybot-tree', 'core-thing'])
+
+ slots.setDisabledPlugins(['social', 'tree'])
+ assert.deepEqual(slots.entries('flybot.sidebar.section').map(entry => entry.id), ['workflow', 'core-thing'])
+ assert.deepEqual(slots.entries('flybot.settings.page').map(entry => entry.id), ['timeline'])
+ slots.setDisabledPlugins([])
+ assert.equal(slots.entries('flybot.sidebar.section').length, 4)
+})
+
+test('every id the four plugin bundles register maps to its own plugin', () => {
+ // The ids the plugin client bundles use today; a new one here has to map too, which is
+ // what keeps the filter honest when a plugin grows a panel.
+ const known = {
+ social: ['social', 'social-approval', 'social-face', 'social-group-strip', 'social-handle', 'social-held-reply', 'social-invite', 'social-paused', 'social-room-end', 'social-room-invite', 'social-thinking'],
+ tree: ['flybot-tree', 'flybot-tree-archived', 'flybot-tree-reorg'],
+ timeline: ['timeline'],
+ workflow: ['workflow'],
+ }
+ for (const [id, ids] of Object.entries(known)) for (const slot of ids) assert.equal(pluginOfSlot(slot), id, slot)
+ assert.equal(pluginOfSlot('core-thing'), undefined)
+})
+
+test('the live registry is the one Settings flips', () => {
+ setDisabledPlugins(['workflow'])
+ assert.equal(registry().entries('flybot.sidebar.section').length, 0)
+ setDisabledPlugins([])
+})
diff --git a/core/test/settings-page-slot.test.js b/core/test/settings-page-slot.test.js
index 73fff6f8b..f37182949 100644
--- a/core/test/settings-page-slot.test.js
+++ b/core/test/settings-page-slot.test.js
@@ -56,6 +56,6 @@ test('a page whose plugin is gone falls back to General, and without plugins the
const nodes = all(tree)
assert.equal(text(nodes.find(node => node.type === 'h2')), 'General')
const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text)
- assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Scheduled tasks'])
+ assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Plugins'])
assert.equal(nodes.some(node => node.props?.entry !== undefined), false)
})
diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch
index 868ee65ad..db7b43fa0 100644
--- a/patches/ds-bot/0001-core-fixes.patch
+++ b/patches/ds-bot/0001-core-fixes.patch
@@ -1496,7 +1496,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
'Model call': '模型调用',
'This window': '这个窗口',
'Error log': '错误日志',
-@@ -771,12 +771,22 @@ const ZH = {
+@@ -771,12 +771,28 @@ const ZH = {
'Add at least one more Bot': '再至少添加一个 Bot',
Members: '成员',
// Connectors and scheduled tasks
@@ -1509,6 +1509,12 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js
+ 'On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend\'s own Bot never gets it.': '默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。',
+ 'No Bots': '没有 Bot',
'Scheduled tasks': '定时任务',
++ 'A switch takes effect the next time FlyBot starts.': '开关在下次启动 FlyBot 时生效。',
++ 'Restart FlyBot to apply the switches you changed.': '重启 FlyBot 后,你改动的开关才会生效。',
++ 'Chat with a friend\'s Bots on a Bot Social server.': '在 Bot Social 服务器上与朋友的 Bot 聊天。',
++ 'Run Bots as a tree under a coordinator.': '把 Bot 组织成树,由协调者带队。',
++ 'Read the team activity as a timeline.': '用时间线查看团队活动。',
++ 'Run workflow graphs across several Bots.': '跨多个 Bot 运行工作流图。',
+ 'Scheduled tasks · {count}': '定时任务 · {count}',
'Every day': '每天',
'Every {count} days': '每 {count} 天',
diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch
index 088eda062..6ae8d764e 100644
--- a/patches/ds-bot/0003-flybot-api-client.patch
+++ b/patches/ds-bot/0003-flybot-api-client.patch
@@ -1642,10 +1642,10 @@ diff --git a/src/client/icons.js b/src/client/icons.js
diff --git a/src/client/index.js b/src/client/index.js
--- a/src/client/index.js
+++ b/src/client/index.js
-@@ -1,44 +1,72 @@
-+import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js'
+@@ -1,44 +1,81 @@
++import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, call as flybotCall, createClientApi as createFlyBotClient } from './flybot-slots.js'
+import { createElement as h } from 'react'
-+import { registry as flybotSlots } from './flybot-registry.js'
++import { registry as flybotSlots, setDisabledPlugins } from './flybot-registry.js'
+import { TURN_FAILURES_CSS, TurnFailures } from './turn-failures.js'
import { call } from './api.js'
-import { isHex, inkText, paintCss } from './inks.js'
@@ -1721,6 +1721,15 @@ diff --git a/src/client/index.js b/src/client/index.js
+ } catch { /* every provide form refused: __flybot__.api above still serves plugins */ }
ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy')
+ ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome')
++ // A plugin the owner switched off (#2564) still loads its browser bundle, so the
++ // switch is read once and its slot entries are dropped before anything draws them.
++ ctx.effect(() => {
++ let live = true
++ flybotCall('plugins/list')
++ .then(answer => { if (live) setDisabledPlugins((answer?.plugins ?? []).filter(plugin => plugin.enabled === false).map(plugin => plugin.id)) })
++ .catch(() => { /* an older host has no switch: every plugin stays visible */ })
++ return () => { live = false }
++ }, 'ds-bot: plugin switches')
const roster = createSource(EMPTY_ROSTER)
+ ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === 'undefined' ? undefined : document), 'ds-bot: tab title')
migrateStorage()
diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch
index 6f12b5dd1..4832db929 100644
--- a/patches/ds-bot/0008-client-bundle.patch
+++ b/patches/ds-bot/0008-client-bundle.patch
@@ -816,7 +816,7 @@ diff --git a/client.js b/client.js
"Model call": "模型调用",
"This window": "这个窗口",
"Error log": "错误日志",
-@@ -821,12 +821,22 @@ var ZH = {
+@@ -821,12 +821,28 @@ var ZH = {
"Add at least one more Bot": "再至少添加一个 Bot",
Members: "成员",
// Connectors and scheduled tasks
@@ -829,6 +829,12 @@ diff --git a/client.js b/client.js
+ "On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.": "默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。",
+ "No Bots": "没有 Bot",
"Scheduled tasks": "定时任务",
++ "A switch takes effect the next time FlyBot starts.": "开关在下次启动 FlyBot 时生效。",
++ "Restart FlyBot to apply the switches you changed.": "重启 FlyBot 后,你改动的开关才会生效。",
++ "Chat with a friend's Bots on a Bot Social server.": "在 Bot Social 服务器上与朋友的 Bot 聊天。",
++ "Run Bots as a tree under a coordinator.": "把 Bot 组织成树,由协调者带队。",
++ "Read the team activity as a timeline.": "用时间线查看团队活动。",
++ "Run workflow graphs across several Bots.": "跨多个 Bot 运行工作流图。",
+ "Scheduled tasks · {count}": "定时任务 · {count}",
"Every day": "每天",
"Every {count} days": "每 {count} 天",
@@ -1166,7 +1172,7 @@ diff --git a/client.js b/client.js
markNoted(error);
}
throw error;
-@@ -1097,6 +1097,406 @@ var call = async (endpoint, payload, signal) => {
+@@ -1097,6 +1097,422 @@ var call = async (endpoint, payload, signal) => {
return result.value;
};
@@ -1381,10 +1387,18 @@ diff --git a/client.js b/client.js
+
+// src/client/flybot-registry.js
+var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"];
++var SLOT_OWNERS = [
++ ["social", /^social(?:$|-)/],
++ ["tree", /^flybot-tree(?:$|-)/],
++ ["timeline", /^timeline$/],
++ ["workflow", /^workflow$/]
++];
++var ownerOf = (id) => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? "")))?.[0];
+function createRegistry() {
+ const entries2 = new Map(SLOTS.map((name) => [name, []]));
+ const listeners2 = /* @__PURE__ */ new Set();
+ let version = 0;
++ let disabled = /* @__PURE__ */ new Set();
+ const changed2 = () => {
+ version += 1;
+ for (const fn of [...listeners2]) fn();
@@ -1404,7 +1418,11 @@ diff --git a/client.js b/client.js
+ changed2();
+ };
+ },
-+ entries: (name) => entries2.get(name) ?? [],
++ entries: (name) => (entries2.get(name) ?? []).filter((entry) => !disabled.has(ownerOf(entry.id))),
++ setDisabledPlugins(ids = []) {
++ disabled = /* @__PURE__ */ new Set([...ids]);
++ changed2();
++ },
+ subscribe(fn) {
+ listeners2.add(fn);
+ return () => listeners2.delete(fn);
@@ -1417,6 +1435,10 @@ diff --git a/client.js b/client.js
+ root.__flybot__ ??= { slots: createRegistry() };
+ return root.__flybot__.slots;
+}
++function setDisabledPlugins(ids = []) {
++ registry().setDisabledPlugins(ids);
++}
++var pluginOfSlot = ownerOf;
+function newChatActionRows(context = {}) {
+ const rows = [];
+ for (const entry of registry().entries("flybot.new-chat.action")) {
@@ -1696,7 +1718,7 @@ diff --git a/client.js b/client.js
var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`;
var holes = (def, eyes) => `${eyes}`;
var MASK_ID = "%MASK%";
-@@ -1242,192 +1242,46 @@ function maskOf(shape) {
+@@ -1242,227 +1242,143 @@ function maskOf(shape) {
}
var DEFAULT_MASK = maskOf(FALLBACK_SHAPE);
@@ -1719,14 +1741,48 @@ diff --git a/client.js b/client.js
- for (const listener of [...listeners2]) listener();
- }
- };
--}
++// src/client/cluster-order.js
++function clusterOrder(people2) {
++ const list = Array.isArray(people2) ? people2 : [];
++ const remote = (bot) => Boolean(bot?.remote);
++ if (!list.some(remote)) return list;
++ const locals = list.filter((bot) => !remote(bot));
++ const remotes = list.filter(remote);
++ if (list.length <= 4) return [...remotes, ...locals];
++ return [...locals, ...remotes];
+ }
-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;
-- }
++
++// src/shared/mentions.js
++var ALL_WORDS = ["all", "everyone", "所有人", "全体"];
++var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i;
++var ADDRESS_BEFORE = /[a-z0-9_./]/i;
++var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g;
++var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length));
++var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end);
++function nameProblem(name) {
++ const text2 = String(name ?? "").trim();
++ if (text2 === "") return "A Bot needs a name";
++ if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break";
++ if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`;
++ return void 0;
++}
++function referenceSpans(text2, labels = []) {
++ const spans = [];
++ for (const label of [...labels].sort((a, b) => b.length - a.length)) {
++ const mention = `@${label}`;
++ for (let at = text2.indexOf(mention); at !== -1; at = text2.indexOf(mention, at + 1)) {
++ const end = at + mention.length;
++ if (at > 0 && ADDRESS_BEFORE.test(text2[at - 1]) || NAME_GOES_ON.test(text2.slice(end)) || overlaps(spans, at, end)) continue;
++ spans.push({ start: at, end, label });
++ break;
++ }
+ }
- const roomsById = {};
- for (const room2 of value.rooms) roomsById[room2.id] = room2;
- const agentsById = {};
@@ -1734,24 +1790,87 @@ diff --git a/client.js b/client.js
- for (const agent of value.agents ?? []) {
- agentsById[agent.id] = agent;
- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id;
-- }
++ return spans.sort((a, b) => a.start - b.start);
++}
++function mentionSpans(text2, bots, { references = [] } = {}) {
++ const value = String(text2 ?? "");
++ const skip = [
++ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })),
++ ...referenceSpans(value, references)
++ ];
++ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length);
++ const spans = [];
++ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) {
++ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue;
++ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name));
++ const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word));
++ if (!bot && !all) continue;
++ const end = at + 1 + (bot ? bot.name.length : all.length);
++ spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true });
++ at = end - 1;
+ }
- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true };
--}
++ return spans;
+ }
-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 unmatchedMentions(text2, bots, { references = [] } = {}) {
++ const value = String(text2 ?? "");
++ const skip = [
++ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })),
++ ...referenceSpans(value, references)
++ ];
++ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length);
++ const missing = [];
++ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) {
++ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue;
++ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name));
++ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2));
++ if (bot || all) {
++ at += 1 + (bot ? bot.name.length : all.length) - 1;
++ continue;
++ }
++ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0];
++ if (word) missing.push(`@${word}`);
++ }
++ return missing;
+ }
-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];
-- }
++
++// src/shared/preview.js
++var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi;
++var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi;
++var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i;
++var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi;
++function stripFence(text2) {
++ let out = String(text2 ?? "");
++ for (let previous; previous !== out; ) {
++ previous = out;
++ out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, "");
+ }
- return { ...state, [sessionId]: own };
--}
--
++ return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, "");
++}
++function visibleText(text2) {
++ let out = String(text2 ?? "");
++ for (let previous; previous !== out; ) {
++ previous = out;
++ const open = out.indexOf("");
++ const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out;
++ out = stripFence(inner);
++ }
++ return stripFence(out).replace(/\s+/g, " ").trim();
+ }
+
-// src/client/themes.js
-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"];
-var THEMES = {
@@ -1774,7 +1893,18 @@ 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" }
-- }
++// src/client/text.js
++var MENTION_ORIGIN = "https://bots.local/";
++function linkMentions(text2, bots, selfId) {
++ if (!text2 || !text2.includes("@")) return text2;
++ const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote)));
++ let out = "";
++ let cursor = 0;
++ for (const span of mentionSpans(text2, ordered)) {
++ if (!span.bot || span.bot.id === selfId) continue;
++ out += `${text2.slice(cursor, span.start)}[${text2.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`;
++ cursor = span.end;
+ }
-};
-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) {
@@ -1854,128 +1984,67 @@ diff --git a/client.js b/client.js
- else delete customThemes[id];
- registry.set((value) => value + 1);
- };
-+// src/client/cluster-order.js
-+function clusterOrder(people2) {
-+ const list = Array.isArray(people2) ? people2 : [];
-+ const remote = (bot) => Boolean(bot?.remote);
-+ if (!list.some(remote)) return list;
-+ const locals = list.filter((bot) => !remote(bot));
-+ const remotes = list.filter(remote);
-+ if (list.length <= 4) return [...remotes, ...locals];
-+ return [...locals, ...remotes];
- }
+-}
-var allThemes = () => ({ ...THEMES, ...customThemes });
-
- // src/shared/mentions.js
- var ALL_WORDS = ["all", "everyone", "所有人", "全体"];
- var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i;
- var ADDRESS_BEFORE = /[a-z0-9_./]/i;
- var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g;
+-
+-// src/shared/mentions.js
+-var ALL_WORDS = ["all", "everyone", "所有人", "全体"];
+-var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i;
+-var ADDRESS_BEFORE = /[a-z0-9_./]/i;
+-var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g;
-var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length));
-+var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length));
- var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end);
- function nameProblem(name) {
+-var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end);
+-function nameProblem(name) {
- const text = String(name ?? "").trim();
- if (text === "") return "A Bot needs a name";
- if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break";
- if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`;
-+ const text2 = String(name ?? "").trim();
-+ if (text2 === "") return "A Bot needs a name";
-+ if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break";
-+ if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`;
- return void 0;
- }
+- return void 0;
+-}
-function referenceSpans(text, labels = []) {
-+function referenceSpans(text2, labels = []) {
- const spans = [];
- for (const label of [...labels].sort((a, b) => b.length - a.length)) {
- const mention = `@${label}`;
+- const spans = [];
+- for (const label of [...labels].sort((a, b) => b.length - a.length)) {
+- const mention = `@${label}`;
- for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) {
-+ for (let at = text2.indexOf(mention); at !== -1; at = text2.indexOf(mention, at + 1)) {
- const end = at + mention.length;
+- const end = at + mention.length;
- if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue;
-+ if (at > 0 && ADDRESS_BEFORE.test(text2[at - 1]) || NAME_GOES_ON.test(text2.slice(end)) || overlaps(spans, at, end)) continue;
- spans.push({ start: at, end, label });
- break;
- }
- }
- return spans.sort((a, b) => a.start - b.start);
- }
+- spans.push({ start: at, end, label });
+- break;
+- }
+- }
+- return spans.sort((a, b) => a.start - b.start);
+-}
-function mentionSpans(text, bots, { references = [] } = {}) {
- const value = String(text ?? "");
-+function mentionSpans(text2, bots, { references = [] } = {}) {
-+ const value = String(text2 ?? "");
- const skip = [
- ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })),
- ...referenceSpans(value, references)
-@@ -1445,24 +1445,86 @@ function mentionSpans(text, bots, { references = [] } = {}) {
- }
- return spans;
- }
-+function unmatchedMentions(text2, bots, { references = [] } = {}) {
-+ const value = String(text2 ?? "");
-+ const skip = [
-+ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })),
-+ ...referenceSpans(value, references)
-+ ];
-+ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length);
-+ const missing = [];
-+ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) {
-+ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue;
-+ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name));
-+ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2));
-+ if (bot || all) {
-+ at += 1 + (bot ? bot.name.length : all.length) - 1;
-+ continue;
-+ }
-+ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0];
-+ if (word) missing.push(`@${word}`);
-+ }
-+ return missing;
-+}
-+
-+// src/shared/preview.js
-+var FENCE_TAG = /<\s*\/?\s*untrusted-(?:message|file)\b[^<>]*>/gi;
-+var FENCE_ATTR = /\bfrom\s*=\s*(?:"[^"]*"|'[^']*')\s*>/gi;
-+var FENCE_TAIL = /<[^<>]*\buntrusted-(?:message|file)\b[^<>]*$/i;
-+var FENCE_BARE = /(?:\/\s*)?untrusted-(?:message|file)\b\s*>?/gi;
-+function stripFence(text2) {
-+ let out = String(text2 ?? "");
-+ for (let previous; previous !== out; ) {
-+ previous = out;
-+ out = out.replace(FENCE_TAG, "").replace(FENCE_ATTR, "");
-+ }
-+ return out.replace(FENCE_TAIL, "").replace(FENCE_BARE, "");
-+}
-+function visibleText(text2) {
-+ let out = String(text2 ?? "");
-+ for (let previous; previous !== out; ) {
-+ previous = out;
-+ const open = out.indexOf("");
-+ const inner = close > open ? out.slice(out.indexOf(">", open) + 1, close) : out;
-+ out = stripFence(inner);
-+ }
-+ return stripFence(out).replace(/\s+/g, " ").trim();
-+}
-
- // src/client/text.js
- var MENTION_ORIGIN = "https://bots.local/";
+- const skip = [
+- ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })),
+- ...referenceSpans(value, references)
+- ];
+- const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length);
+- const spans = [];
+- for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) {
+- if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue;
+- const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name));
+- const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word));
+- if (!bot && !all) continue;
+- const end = at + 1 + (bot ? bot.name.length : all.length);
+- spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true });
+- at = end - 1;
+- }
+- return spans;
+-}
+-
+-// src/client/text.js
+-var MENTION_ORIGIN = "https://bots.local/";
-function linkMentions(text, bots, selfId) {
- if (!text || !text.includes("@")) return text;
-+function linkMentions(text2, bots, selfId) {
-+ if (!text2 || !text2.includes("@")) return text2;
-+ const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote)));
- let out = "";
- let cursor = 0;
+- let out = "";
+- let cursor = 0;
- for (const span of mentionSpans(text, bots)) {
-+ for (const span of mentionSpans(text2, ordered)) {
- if (!span.bot || span.bot.id === selfId) continue;
+- if (!span.bot || span.bot.id === selfId) continue;
- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`;
-+ out += `${text2.slice(cursor, span.start)}[${text2.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`;
- cursor = span.end;
- }
+- cursor = span.end;
+- }
- return cursor === 0 ? text : out + text.slice(cursor);
+ return cursor === 0 ? text2 : out + text2.slice(cursor);
}
@@ -2205,7 +2274,7 @@ diff --git a/client.js b/client.js
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;
-@@ -1624,1039 +1624,3146 @@ function usageOf(list, ids) {
+@@ -1624,694 +1624,2752 @@ 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 });
}
@@ -2471,18 +2540,11 @@ diff --git a/client.js b/client.js
- const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" });
- animation.onfinish = () => {
- delete node.dataset.morph;
-- };
-- animation.oncancel = () => {
-- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph;
-- };
-- });
+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 useLinger(value, ms) {
-- const [kept, setKept] = (0, import_react.useState)(value);
++}
+function countCards(state, sessionId, callIds, delta) {
+ const own = { ...state[sessionId] };
+ for (const callId of callIds) {
@@ -2499,9 +2561,7 @@ diff --git a/client.js b/client.js
+var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle";
+var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working";
+function useGaze(ref, enabled) {
- (0, import_react.useEffect)(() => {
-- if (value) {
-- setKept(value);
++ (0, import_react.useEffect)(() => {
+ if (!enabled) return void 0;
+ let frame = 0;
+ let point = null;
@@ -2513,11 +2573,14 @@ diff --git a/client.js b/client.js
+ const clamp = (value) => Math.max(-1, Math.min(1, value));
+ node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2));
+ node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2));
-+ };
+ };
+- animation.oncancel = () => {
+- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph;
+ const onMove = (event) => {
+ point = { x: event.clientX, y: event.clientY };
+ if (!frame) frame = requestAnimationFrame(place);
-+ };
+ };
+- });
+ const onLeave = () => {
+ point = null;
+ ref.current?.style.setProperty("--bt-gx", "0");
@@ -2531,7 +2594,9 @@ diff --git a/client.js b/client.js
+ document.documentElement.removeEventListener("pointerleave", onLeave);
+ };
+ }, [enabled]);
-+}
+ }
+-function useLinger(value, ms) {
+- const [kept, setKept] = (0, import_react.useState)(value);
+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));
@@ -2611,7 +2676,9 @@ diff --git a/client.js b/client.js
+function useSettled(state) {
+ const prev = (0, import_react.useRef)(state);
+ const [settled, setSettled] = (0, import_react.useState)(false);
-+ (0, import_react.useEffect)(() => {
+ (0, import_react.useEffect)(() => {
+- if (value) {
+- setKept(value);
+ const was = prev.current;
+ prev.current = state;
+ if (state !== "idle") {
@@ -2819,42 +2886,6 @@ diff --git a/client.js b/client.js
-.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0}
-.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)}
-.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important}
--
--.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
--.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0}
--.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap}
--.bt-brand-whale{display:inline-flex;color:var(--bt-accent)}
--.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease}
--.bt-round:hover{background:var(--bt-hover)}
--.bt-round:active{transform:scale(.94)}
--.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto}
--.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px}
--.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
--.bt-tile:hover{background:var(--bt-hover)}
--.bt-tile[aria-current=page]{background:var(--bt-active)}
--.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis}
--.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease}
--.bt-row:hover{background:var(--bt-hover)}
--.bt-row[aria-current=page]{background:var(--bt-active)}
--.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto}
--.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400}
--.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
--.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
--.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
--.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)}
--.bt-unread-wait{background:var(--bt-star)}
--.bt-rail{align-items:center;padding:0 0 8px}
--.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px}
--.bt-side-foot{display:flex;padding-top:8px}
--.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease}
--.bt-connect:hover{background:var(--bt-hover)}
--.bt-connect svg{flex:none;width:15px;height:15px}
--.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0}
--.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center}
--.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
--@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}}
--@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}}
+// src/client/room-invite-preview.js
+var INVITE_PREVIEW_NAME_MAX = 60;
+function stripInviteMarkers(text2) {
@@ -2936,29 +2967,41 @@ diff --git a/client.js b/client.js
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`;
+}
--.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none}
--.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}}
--.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)}
--.bt-pill:active{transform:scale(.965)}
--.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
--.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}}
--.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}}
--.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}}
--.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)}
--.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms}
--.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)}
--.bt-pill-mem-ico svg{width:16px;height:16px}
--.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both}
--@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}}
--.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
--@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}}
--.bt-swap{color:var(--bt-ink-3);font-size:12px}
--.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease}
--.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
--.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none}
--.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)}
--.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)}
--@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}}
+-.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+-.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0}
+-.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap}
+-.bt-brand-whale{display:inline-flex;color:var(--bt-accent)}
+-.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease}
+-.bt-round:hover{background:var(--bt-hover)}
+-.bt-round:active{transform:scale(.94)}
+-.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto}
+-.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px}
+-.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease}
+-.bt-tile:hover{background:var(--bt-hover)}
+-.bt-tile[aria-current=page]{background:var(--bt-active)}
+-.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis}
+-.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease}
+-.bt-row:hover{background:var(--bt-hover)}
+-.bt-row[aria-current=page]{background:var(--bt-active)}
+-.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto}
+-.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400}
+-.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+-.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+-.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+-.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)}
+-.bt-unread-wait{background:var(--bt-star)}
+-.bt-rail{align-items:center;padding:0 0 8px}
+-.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px}
+-.bt-side-foot{display:flex;padding-top:8px}
+-.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease}
+-.bt-connect:hover{background:var(--bt-hover)}
+-.bt-connect svg{flex:none;width:15px;height:15px}
+-.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0}
+-.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center}
+-.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
+-@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}}
+-@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}}
+// src/client/approval-claims.js
+var claims = /* @__PURE__ */ new Map();
+var claimListeners = /* @__PURE__ */ new Set();
@@ -2985,6 +3028,35 @@ diff --git a/client.js b/client.js
+ };
+}
+-.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none}
+-.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}}
+-.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)}
+-.bt-pill:active{transform:scale(.965)}
+-.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
+-.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}}
+-.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}}
+-.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}}
+-.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)}
+-.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms}
+-.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)}
+-.bt-pill-mem-ico svg{width:16px;height:16px}
+-.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both}
+-@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}}
+-.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
+-@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}}
+-.bt-swap{color:var(--bt-ink-3);font-size:12px}
+-.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease}
+-.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
+-.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none}
+-.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)}
+-.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)}
+-@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}}
++// src/shared/approval-waiting.js
++var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]);
++function isApprovalWaiting(status) {
++ return WAITING_STATUSES.includes(status);
++}
+
-.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0}
-.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere}
-.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px}
@@ -3005,11 +3077,25 @@ diff --git a/client.js b/client.js
-.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px}
-.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}}
-.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)}
-+// src/shared/approval-waiting.js
-+var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]);
-+function isApprovalWaiting(status) {
-+ return WAITING_STATUSES.includes(status);
-+}
++// src/shared/held-retry.js
++var TERMINAL = [
++ // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError).
++ /no such remote bot/i,
++ /is not shared with your Bots/i,
++ /is no longer shared with you/i,
++ // Only chat.public is a share taken back (#971). capability_not_granted is Bot Social's generic
++ // "peer has not granted " code, so the bare code does not decide (#988).
++ /has not granted chat\.public/i,
++ // The codes, when a transport passes the code instead of its sentence.
++ /bot_not_shared/i,
++ /bot_not_found/i
++];
++var canRetry = (record) => {
++ if (!record) return true;
++ if (record.retry === false || record.noRetry === true) return false;
++ const said = `${record.error ?? ""} ${record.code ?? ""}`;
++ return !TERMINAL.some((reason) => reason.test(said));
++};
-.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards}
-body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)}
@@ -3083,24 +3169,12 @@ diff --git a/client.js b/client.js
-.bt-q-ownrow .bt-q-input{color:var(--bt-ink)}
-.bt-q-raised .bt-q-foot{padding:0 8px 8px}
-@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}}
-+// src/shared/held-retry.js
-+var TERMINAL = [
-+ // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError).
-+ /no such remote bot/i,
-+ /is not shared with your Bots/i,
-+ /is no longer shared with you/i,
-+ // Only chat.public is a share taken back (#971). capability_not_granted is Bot Social's generic
-+ // "peer has not granted " code, so the bare code does not decide (#988).
-+ /has not granted chat\.public/i,
-+ // The codes, when a transport passes the code instead of its sentence.
-+ /bot_not_shared/i,
-+ /bot_not_found/i
-+];
-+var canRetry = (record) => {
-+ if (!record) return true;
-+ if (record.retry === false || record.noRetry === true) return false;
-+ const said = `${record.error ?? ""} ${record.code ?? ""}`;
-+ return !TERMINAL.some((reason) => reason.test(said));
++// src/client/reason-line.js
++var REASON_POINTS = 200;
++var reasonLine = (reason) => {
++ if (typeof reason !== "string" || reason.trim() === "") return null;
++ const full2 = reason.toWellFormed();
++ return { text: fits(full2, REASON_POINTS) ? full2 : [...full2].slice(0, REASON_POINTS).join("") + "…", title: full2 };
+};
-.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out}
@@ -3146,14 +3220,7 @@ diff --git a/client.js b/client.js
-.bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer}
-.bt-send:disabled{opacity:.35;cursor:default}
-.bt-error{padding:0 14px 10px;color:#e02135;font-size:12px}
-+// src/client/reason-line.js
-+var REASON_POINTS = 200;
-+var reasonLine = (reason) => {
-+ if (typeof reason !== "string" || reason.trim() === "") return null;
-+ const full2 = reason.toWellFormed();
-+ return { text: fits(full2, REASON_POINTS) ? full2 : [...full2].slice(0, REASON_POINTS).join("") + "…", title: full2 };
-+};
-
+-
-.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
-.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0}
-.bt-dialog .bt-head{height:62px}
@@ -3275,7 +3342,77 @@ diff --git a/client.js b/client.js
-.bt-kv{display:flex;justify-content:space-between;gap:8px;font-size:12px;line-height:20px;color:var(--bt-ink-2)}
-.bt-kv span:last-child{color:var(--bt-ink);font-variant-numeric:tabular-nums}
-.bt-danger{color:#e02135}
--
++// src/client/brand.js
++var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z";
++var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z";
++var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z";
++function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) {
++ return ``;
++}
++function brandTitle(title) {
++ const text2 = String(title ?? "");
++ if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot";
++ if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`;
++ if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot");
++ return text2;
++}
++function sessionTabLabel(roster, sessionId) {
++ if (!roster || !sessionId) return "";
++ const bot = roster.byId?.[sessionId];
++ const room2 = roster.roomsById?.[sessionId];
++ if (bot?.remote) {
++ return friendBotTitle(bot);
++ }
++ 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?.origin === "face-to-face") return faceRowTitle(roomTitle(room2));
++ if (room2) return roomTitle(room2);
++ return bot?.name || "";
++}
++function installTabTitle(sessions, roster, doc = globalThis.document) {
++ if (!doc || !sessions || !roster) return () => {
++ };
++ const sync = () => {
++ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0);
++ const label = sessionTabLabel(roster.getSnapshot(), row?.id);
++ if (!label) return;
++ const next = brandTitle(`${label} — FlyBot`);
++ if (doc.title !== next) doc.title = next;
++ };
++ sync();
++ const offSessions = sessions.subscribe(sync);
++ const offRoster = roster.subscribe(sync);
++ return () => {
++ offSessions?.();
++ offRoster?.();
++ };
++}
++function installBrandChrome() {
++ if (typeof document === "undefined") return () => {
++ };
++ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`;
++ let link = document.getElementById("flybot-favicon");
++ if (!link) {
++ link = document.createElement("link");
++ link.id = "flybot-favicon";
++ link.rel = "icon";
++ link.type = "image/svg+xml";
++ document.head.appendChild(link);
++ }
++ link.href = href;
++ const retitle = () => {
++ const next = brandTitle(document.title);
++ if (next !== document.title) document.title = next;
++ };
++ retitle();
++ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null;
++ const node = document.querySelector("title") ?? document.head;
++ observer?.observe(node, { childList: true, characterData: true, subtree: true });
++ return () => {
++ observer?.disconnect();
++ link.remove();
++ };
++}
+
-/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */
-.bt-field{display:flex;flex-direction:column;gap:6px}
-.bt-field-label{font-size:13px;line-height:18px;font-weight:500}
@@ -3326,160 +3463,6 @@ diff --git a/client.js b/client.js
-.bt-drawer-model .bt-note{margin-top:6px}
-.bt-drawer-permission{display:flex;flex-direction:column}
-.bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135}
--
--/* Settings dialog: a 198px page list beside the page. */
--.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
--.bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out}
--body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)}
--.bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
--.bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))}
--:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none}
--:is(.bt-settings-layer,.bt-mem-layer)[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
--:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]>[role=dialog]{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards}
--@keyframes bt-dialog-rise{from{transform:translateY(12px) scale(.96)}}
--@keyframes bt-dialog-sink{to{opacity:0;transform:translateY(6px) scale(.975);filter:blur(3px)}}
--@media (prefers-reduced-motion:reduce){.bt-settings{transition:none}.bt-settings,.bt-scrim,.bt-settings-page>*{animation:bt-fade .12s ease-out!important}}
--.bt-settings-nav{position:relative;width:198px;flex:none;padding:16px 8px;border-right:.5px solid var(--bt-line);display:flex;flex-direction:column;gap:2px;background:var(--bt-sidebar);box-sizing:border-box}
--.bt-settings-item{position:relative;z-index:1;display:flex;align-items:center;gap:8px;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left;transition:background-color .12s ease,color .12s ease}
--.bt-settings-item:hover{color:var(--bt-ink);background:var(--bt-hover)}
--.bt-settings-item[aria-current=page]{background:var(--bt-active);color:var(--bt-ink)}
--.bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)}
--.bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent}
--.bt-settings-shell{margin-top:auto}
--/* Usage while another page is open: out of the flow at Usage's own width, so it keeps
-- its layout, and not rendered. */
--.bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none}
--/* The page column takes the dialog's final size at once, so while the dialog resizes
-- only its edge moves and the page does not lay out again every frame. */
--.bt-settings-main{flex:none;display:flex;flex-direction:column;min-width:0;width:calc(var(--bt-set-w) - 198px);height:var(--bt-set-h)}
--.bt-settings-head{display:flex;align-items:center;gap:6px;padding:16px 56px 4px 20px}
--.bt-settings-head h2{margin:0;font-size:15px;line-height:24px;font-weight:600;animation:bt-veil-in .2s ease-out}
--.bt-settings-head>.bt-icon-btn{animation:bt-back-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out}
--@keyframes bt-back-in{from{translate:6px 0;scale:.8}}
--.bt-settings-scroll{flex:1;overflow-y:auto;padding:12px 20px 24px;display:flex;flex-direction:column;gap:20px;scrollbar-width:thin}
--.bt-settings-page>:not(.bt-us){animation:bt-page-up ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .22s ease-out backwards}
--.bt-settings-page[data-move=in]>:not(.bt-us){animation-name:bt-page-in,bt-veil-in}
--.bt-settings-page[data-move=out]>:not(.bt-us){animation-name:bt-page-out,bt-veil-in}
--.bt-settings-page>:nth-child(2){animation-delay:30ms}
--.bt-settings-page>:nth-child(3){animation-delay:60ms}
--.bt-settings-page>:nth-child(4){animation-delay:90ms}
--.bt-settings-page>:nth-child(n+5){animation-delay:120ms}
--@keyframes bt-page-up{from{transform:translateY(10px)}}
--@keyframes bt-page-in{from{transform:translateX(18px)}}
--@keyframes bt-page-out{from{transform:translateX(-18px)}}
--.bt-settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
--.bt-settings-close:hover{background:var(--bt-active);color:var(--bt-ink)}
--.bt-settings-form{display:flex;flex-direction:column;gap:16px}
--.bt-set-section h3{font-size:13px;line-height:18px;font-weight:600;margin:0 0 8px;padding:0 4px}
--.bt-set-card{border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card);display:flex;flex-direction:column}
--.bt-set-card>*+*{border-top:.5px solid var(--bt-line)}
--.bt-set-row{display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;text-align:left;width:100%;box-sizing:border-box;border-radius:0}
--button.bt-set-row{cursor:pointer}
--button.bt-set-row:hover{background:var(--bt-hover)}
--.bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)}
--.bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px}
--.bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px}
--.bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}
--.bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))}
--.bt-set-control{flex:none;display:flex;align-items:center;gap:6px}
--.bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right}
--.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0}
--.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px}
--.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none}
--
--/* Settings → Connectors. */
--.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px}
--.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0}
--.bt-connector-mark{flex:none;width:36px;height:36px;border-radius:10px;background:var(--bt-card);border:.5px solid var(--bt-line);display:flex;align-items:center;justify-content:center;color:var(--bt-ink)}
--.bt-connector-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
--.bt-connector-name{font-size:14px;line-height:20px;font-weight:500}
--.bt-connector-status{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-connector[data-state=ready] .bt-connector-status{color:var(--bt-ink-2)}
--.bt-connector[data-state=error] .bt-connector-status{color:#e02135;white-space:normal}
--.bt-connector-actions{flex:none;display:flex;gap:6px}
--.bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
--.bt-connector-form-row{display:flex;align-items:center;gap:6px}
--.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)}
--.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135}
-+// src/client/brand.js
-+var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z";
-+var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z";
-+var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z";
-+function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) {
-+ return ``;
-+}
-+function brandTitle(title) {
-+ const text2 = String(title ?? "");
-+ if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot";
-+ if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`;
-+ if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot");
-+ return text2;
-+}
-+function sessionTabLabel(roster, sessionId) {
-+ if (!roster || !sessionId) return "";
-+ const bot = roster.byId?.[sessionId];
-+ const room2 = roster.roomsById?.[sessionId];
-+ if (bot?.remote) {
-+ return friendBotTitle(bot);
-+ }
-+ 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?.origin === "face-to-face") return faceRowTitle(roomTitle(room2));
-+ if (room2) return roomTitle(room2);
-+ return bot?.name || "";
-+}
-+function installTabTitle(sessions, roster, doc = globalThis.document) {
-+ if (!doc || !sessions || !roster) return () => {
-+ };
-+ const sync = () => {
-+ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0);
-+ const label = sessionTabLabel(roster.getSnapshot(), row?.id);
-+ if (!label) return;
-+ const next = brandTitle(`${label} — FlyBot`);
-+ if (doc.title !== next) doc.title = next;
-+ };
-+ sync();
-+ const offSessions = sessions.subscribe(sync);
-+ const offRoster = roster.subscribe(sync);
-+ return () => {
-+ offSessions?.();
-+ offRoster?.();
-+ };
-+}
-+function installBrandChrome() {
-+ if (typeof document === "undefined") return () => {
-+ };
-+ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`;
-+ let link = document.getElementById("flybot-favicon");
-+ if (!link) {
-+ link = document.createElement("link");
-+ link.id = "flybot-favicon";
-+ link.rel = "icon";
-+ link.type = "image/svg+xml";
-+ document.head.appendChild(link);
-+ }
-+ link.href = href;
-+ const retitle = () => {
-+ const next = brandTitle(document.title);
-+ if (next !== document.title) document.title = next;
-+ };
-+ retitle();
-+ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null;
-+ const node = document.querySelector("title") ?? document.head;
-+ observer?.observe(node, { childList: true, characterData: true, subtree: true });
-+ return () => {
-+ observer?.disconnect();
-+ link.remove();
-+ };
-+}
-
--/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the
-- shell offers them and CSS-module name suffixes elsewhere. */
--[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important}
--[class*="_footArea"]{display:flex!important;flex-direction:row-reverse;align-items:center;gap:9px;padding:8px 6px 6px!important}
--[class*="_footArea"]>[class*="_footerActions"]{flex:1 1 auto;min-width:0}
--[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important}
--[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important}
--[class*="_widthHandle"]{display:none!important}
+// src/client/icons.js
+var import_react2 = require("react");
+function PlusIcon() {
@@ -3612,65 +3595,65 @@ diff --git a/client.js b/client.js
+ );
+}
--.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out}
--.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center}
--.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px}
--.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear}
--.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8}
--.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite}
--.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite}
--@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}
--@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}}
--.bt-voice-orb>.bt-mark{position:relative}
--.bt-voice-name{font-size:16px;line-height:22px;font-weight:500}
--.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px}
--.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
--.bt-voice-note{font-size:12px;color:#e02135}
--.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease}
--.bt-voice-setup:hover{background:var(--bt-active)}
--.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px}
--.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease}
--.bt-voice-btn:hover{background:var(--bt-active)}
--.bt-voice-btn:active{transform:scale(.94)}
--.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)}
--.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)}
--.bt-voice-end{background:#ff3e51;color:#fff}
--.bt-voice-end:hover{background:#e02135}
--@keyframes bt-fade{from{opacity:0}to{opacity:1}}
--@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}}
--@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}}
--.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))}
--/* The bubble's row: reactions hang below it, so the hover toolbar (and a group
-- sender's avatar) line up with the bubble alone. */
--.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%}
--.bt-msg-line>.bt-bubble{max-width:none;min-width:0}
--/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */
--.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))}
--.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s}
--.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1}
--.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible}
--.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease}
--.bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)}
--.bt-tool:active{background:var(--bt-active)}
--.bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px}
--.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out}
--.bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
--.bt-react-strip button:nth-child(2){animation-delay:20ms}
--.bt-react-strip button:nth-child(3){animation-delay:40ms}
--.bt-react-strip button:nth-child(4){animation-delay:60ms}
--.bt-react-strip button:nth-child(n+5){animation-delay:80ms}
--.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)}
--.bt-react-strip button:active{transform:scale(.92)}
--.bt-reacts{display:flex;gap:4px;margin-top:4px}
--.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}}
--.bt-react:hover{background:var(--bt-hover)}
--.bt-react:active{scale:.92}
--@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}}
--.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none}
--.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease}
--button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))}
--.bt-you-wrap{display:inline-flex;padding:0}
--.bt-connect-foot{width:100%;height:36px}
+-/* Settings dialog: a 198px page list beside the page. */
+-.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+-.bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out}
+-body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)}
+-.bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+-.bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))}
+-:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none}
+-:is(.bt-settings-layer,.bt-mem-layer)[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
+-:is(.bt-settings-layer,.bt-mem-layer)[data-leaving]>[role=dialog]{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards}
+-@keyframes bt-dialog-rise{from{transform:translateY(12px) scale(.96)}}
+-@keyframes bt-dialog-sink{to{opacity:0;transform:translateY(6px) scale(.975);filter:blur(3px)}}
+-@media (prefers-reduced-motion:reduce){.bt-settings{transition:none}.bt-settings,.bt-scrim,.bt-settings-page>*{animation:bt-fade .12s ease-out!important}}
+-.bt-settings-nav{position:relative;width:198px;flex:none;padding:16px 8px;border-right:.5px solid var(--bt-line);display:flex;flex-direction:column;gap:2px;background:var(--bt-sidebar);box-sizing:border-box}
+-.bt-settings-item{position:relative;z-index:1;display:flex;align-items:center;gap:8px;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left;transition:background-color .12s ease,color .12s ease}
+-.bt-settings-item:hover{color:var(--bt-ink);background:var(--bt-hover)}
+-.bt-settings-item[aria-current=page]{background:var(--bt-active);color:var(--bt-ink)}
+-.bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)}
+-.bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent}
+-.bt-settings-shell{margin-top:auto}
+-/* Usage while another page is open: out of the flow at Usage's own width, so it keeps
+- its layout, and not rendered. */
+-.bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none}
+-/* The page column takes the dialog's final size at once, so while the dialog resizes
+- only its edge moves and the page does not lay out again every frame. */
+-.bt-settings-main{flex:none;display:flex;flex-direction:column;min-width:0;width:calc(var(--bt-set-w) - 198px);height:var(--bt-set-h)}
+-.bt-settings-head{display:flex;align-items:center;gap:6px;padding:16px 56px 4px 20px}
+-.bt-settings-head h2{margin:0;font-size:15px;line-height:24px;font-weight:600;animation:bt-veil-in .2s ease-out}
+-.bt-settings-head>.bt-icon-btn{animation:bt-back-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out}
+-@keyframes bt-back-in{from{translate:6px 0;scale:.8}}
+-.bt-settings-scroll{flex:1;overflow-y:auto;padding:12px 20px 24px;display:flex;flex-direction:column;gap:20px;scrollbar-width:thin}
+-.bt-settings-page>:not(.bt-us){animation:bt-page-up ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .22s ease-out backwards}
+-.bt-settings-page[data-move=in]>:not(.bt-us){animation-name:bt-page-in,bt-veil-in}
+-.bt-settings-page[data-move=out]>:not(.bt-us){animation-name:bt-page-out,bt-veil-in}
+-.bt-settings-page>:nth-child(2){animation-delay:30ms}
+-.bt-settings-page>:nth-child(3){animation-delay:60ms}
+-.bt-settings-page>:nth-child(4){animation-delay:90ms}
+-.bt-settings-page>:nth-child(n+5){animation-delay:120ms}
+-@keyframes bt-page-up{from{transform:translateY(10px)}}
+-@keyframes bt-page-in{from{transform:translateX(18px)}}
+-@keyframes bt-page-out{from{transform:translateX(-18px)}}
+-.bt-settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0}
+-.bt-settings-close:hover{background:var(--bt-active);color:var(--bt-ink)}
+-.bt-settings-form{display:flex;flex-direction:column;gap:16px}
+-.bt-set-section h3{font-size:13px;line-height:18px;font-weight:600;margin:0 0 8px;padding:0 4px}
+-.bt-set-card{border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card);display:flex;flex-direction:column}
+-.bt-set-card>*+*{border-top:.5px solid var(--bt-line)}
+-.bt-set-row{display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;text-align:left;width:100%;box-sizing:border-box;border-radius:0}
+-button.bt-set-row{cursor:pointer}
+-button.bt-set-row:hover{background:var(--bt-hover)}
+-.bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)}
+-.bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px}
+-.bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px}
+-.bt-set-copy{flex:1;display:flex;flex-direction:column;gap:2px;min-width:0}
+-.bt-set-hint{font-size:12px;line-height:16px;color:color-mix(in srgb,var(--bt-ink-2) 50%,var(--bt-ink-3))}
+-.bt-set-control{flex:none;display:flex;align-items:center;gap:6px}
+-.bt-set-value{font-size:13px;color:var(--bt-ink-2);text-align:right}
+-.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0}
+-.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px}
+-.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none}
+// src/client/motion.js
+var import_react3 = require("react");
+var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
@@ -3875,199 +3858,20 @@ diff --git a/client.js b/client.js
+ return [value || kept, !value && Boolean(kept)];
+}
--.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box}
--.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box}
--.bt-menu button:hover{background:var(--bt-hover)}
--.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px}
--.bt-menu .bt-danger{color:#c21d2e}
--.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px}
--.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in}
--.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards}
--.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums}
--.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
--.bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out}
--.bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
--.bt-palette-layer[data-leaving]{pointer-events:none}
--.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in}
--.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards}
--@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}}
--.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)}
--.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px}
--.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0}
--.bt-palette-head input::placeholder{color:var(--bt-ink-3)}
--.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
--.bt-palette-clear:hover{color:var(--bt-ink)}
--.bt-palette-clear svg{width:12px;height:12px}
--.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box}
--.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)}
--.bt-palette-list>.bt-thumb{left:8px;right:8px}
--:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent}
--:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1}
--.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none}
--.bt-palette-section:first-of-type{padding-top:0}
--.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%}
--.bt-palette-row[aria-selected=true]{background:var(--bt-active)}
--.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center}
--.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
--.bt-palette-cmd svg{width:14px;height:14px}
--.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)}
--.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
--.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)}
--.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out}
--.bt-palette-empty-icon{color:var(--bt-line-2)}
--.bt-palette-empty-icon svg{width:32px;height:32px}
--.bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)}
--.bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)}
--@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}}
--.bt-account-menu{width:224px}
--.bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex}
--.bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))}
--/* Popover keyframes move translate and scale, which compose with a transform the popover
-- already has. */
--@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}}
--@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}}
--@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}}
--@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}}
--@keyframes bt-check-pop{from{opacity:0;scale:.4}}
--@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}}
--.bt-brand:hover{background:var(--bt-hover)}
--.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px}
--.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px}
--.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0}
--.bt-pick:hover{background:var(--bt-hover)}
--.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)}
--.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1}
--.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px}
--.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px}
--.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px}
--.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box}
--.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)}
--.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0}
--.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)}
--.bt-states{display:flex;flex-wrap:wrap;gap:4px}
--.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer}
--.bt-state:hover{color:var(--bt-ink)}
--.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
--.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden}
--.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)}
--body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)}
--body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)}
--.bt-tabs{flex-wrap:wrap}
--.bt-soft:disabled{opacity:.4;cursor:default}
--.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px}
--.bt-members{display:flex;flex-direction:column;gap:1px}
--.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out}
--.bt-member:hover{background:var(--bt-hover)}
--.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left}
--.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent}
--.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer}
--.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)}
--.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease}
--.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1}
--.bt-add{color:var(--bt-ink-2);margin-top:2px}
--.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)}
--.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out}
--.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)}
--.bt-motion-seg{width:216px}
--.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}}
--.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)}
--.bt-seg button:disabled{opacity:.4;cursor:default}
--.bt-seg button:active:not(:disabled){scale:.96}
--.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)}
--.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none}
--@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
--@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}}
--
--/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */
--.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease}
--.bt-mem-row:hover{background:var(--bt-hover)}
--.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)}
--.bt-mem-row-copy{flex:1;text-align:left}
--.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px}
--.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
--.bt-mem-scrim{animation:bt-fade .2s ease-out}
--.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
--.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box}
--.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none}
--.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6}
--.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px}
--.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap}
--.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums}
--.bt-mem-head-gap{flex:1}
--.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right}
--.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none}
--.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px}
--.bt-mem-summary{transition:opacity .3s ease}
--.bt-mem-summary[data-stale]{opacity:.55}
--.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600}
--.bt-mem-summary h3:first-child{margin-top:0}
--.bt-mem-summary p{margin:0;font-size:15px;line-height:26px}
--.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both}
--.bt-mem-summary>:nth-child(2){animation-delay:.03s}
--.bt-mem-summary>:nth-child(3){animation-delay:.06s}
--.bt-mem-summary>:nth-child(4){animation-delay:.09s}
--.bt-mem-summary>:nth-child(5){animation-delay:.12s}
--.bt-mem-summary>:nth-child(6){animation-delay:.15s}
--.bt-mem-summary>:nth-child(n+7){animation-delay:.18s}
--@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}}
--.bt-mem-skel{display:flex;flex-direction:column;gap:24px}
--.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px}
--.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite}
--.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px}
--@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}}
--.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)}
--.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both}
--.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px}
--.bt-mem-empty-mark svg{width:22px;height:22px}
--.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600}
--.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)}
--.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)}
--.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere}
--.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out}
--.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
--.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600}
--.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
--.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap}
--.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500}
--.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
--.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease}
--.bt-mem-entry[data-busy]{opacity:.4}
--.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
--.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere}
--.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
--.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease}
--.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1}
--.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)}
--.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px}
--@media (hover:none){.bt-mem-remove{opacity:1}}
--.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px}
--.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px}
--.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
--.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px}
--.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none}
--.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere}
--.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px}
--.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere}
--.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards}
--.bt-mem-changes li[data-ok] svg{color:var(--bt-green)}
--.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease}
--.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)}
--.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0}
--.bt-mem-input::placeholder{color:var(--bt-ink-3)}
--.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}}
--.bt-mem-send:disabled{opacity:.22;cursor:default}
--.bt-mem-send:not(:disabled):active{scale:.92}
--.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
--.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite}
--@keyframes bt-mem-spin{to{transform:rotate(360deg)}}
--@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}}
--@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}}
+-/* Settings → Connectors. */
+-.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px}
+-.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0}
+-.bt-connector-mark{flex:none;width:36px;height:36px;border-radius:10px;background:var(--bt-card);border:.5px solid var(--bt-line);display:flex;align-items:center;justify-content:center;color:var(--bt-ink)}
+-.bt-connector-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
+-.bt-connector-name{font-size:14px;line-height:20px;font-weight:500}
+-.bt-connector-status{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-connector[data-state=ready] .bt-connector-status{color:var(--bt-ink-2)}
+-.bt-connector[data-state=error] .bt-connector-status{color:#e02135;white-space:normal}
+-.bt-connector-actions{flex:none;display:flex;gap:6px}
+-.bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+-.bt-connector-form-row{display:flex;align-items:center;gap:6px}
+-.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)}
+-.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135}
+// src/client/connector-bots.js
+var import_react4 = require("react");
+function ownBots(roster) {
@@ -4114,89 +3918,7 @@ diff --git a/client.js b/client.js
+ );
+}
--.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease}
--.bt-thumb[data-hidden]{opacity:0}
--[data-morph]{overflow:hidden!important}
--.bt-veil{animation:bt-veil-in .22s ease-out}
--.bt-with-icon{display:inline-flex;align-items:center;gap:4px}
--.bt-with-icon svg{width:14px;height:14px}
--.bt-tag-admin{display:inline-flex;align-items:center;gap:4px}
--.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
--.bt-create-inline .bt-create-card{max-width:none}
--.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards}
--.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards}
--.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms}
--.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms}
--.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out}
--.bt-drawer-name-input{animation:bt-veil-in .16s ease-out}
--.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
--:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}}
--:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9}
--:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96}
--@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}}
--`;
--var CONVERSATION_CSS = `
--.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)}
--.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px}
--.bt-think summary::-webkit-details-marker{display:none}
--.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
--.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap}
--.bt-event>span{padding:0 2px}
--.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease}
--.bt-event button:hover{background:var(--bt-hover)}
--.bt-event button:active{background:var(--bt-active)}
--.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px}
--.bt-event-whole>*{flex:none}
--.bt-event-whole>span:first-child{margin-right:2px}
--/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */
--.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap}
--[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap}
--.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)}
--.bt-group-replies{display:contents}
--/* A Bot chat is flat in every work-details mode, without changing the user's mode
-- (it is a DSH-wide setting): step groups lose their header and stay open, and what a
-- mode folds away in a finished turn stays shown. TurnProcessCell also opens that
-- fold; these rules cover the first frame before it does. */
--[data-step-process]>div:first-child{display:none!important}
--[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important}
--:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important}
--[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important}
--[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important}
--/* The transcript runs under a floating header pill. */
--header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center}
--header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1}
--header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto}
--header[class*="_header"] .bt-head{flex:1 1 auto;width:100%}
--[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)}
--/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators),
-- without its copy and branch row; a tail with nothing in it takes no room. */
--[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important}
--[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important}
--/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not
-- the shell's new-session hero: no headline, no workspace or mode picker, and the
-- composer stays docked at the bottom. */
--[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important}
--[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important}
--[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important}
--[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important}
--/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */
--[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important}
--/* The shell's running status only hosts the activity row (.bt-typing). */
--[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important}
--[data-chat-running]>span:not([role=status]){display:none!important}
--[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important}
--.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards}
--.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards}
--.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0}
--.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out}
--.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))}
--.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)}
--.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18}
--.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards}
--.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards}
--.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap}
+-/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the
+// src/client/schedules.js
+var import_react5 = require("react");
+var POLL_MS = 3e4;
@@ -5962,183 +5684,135 @@ diff --git a/client.js b/client.js
+[data-status=sign-in]>.bt-app-status{color:#c46a00}
+
+/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the
-+ shell offers them and CSS-module name suffixes elsewhere. */
-+[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important}
-+[class*="_footArea"]{display:flex!important;flex-direction:row-reverse;align-items:center;gap:9px;padding:8px 6px 6px!important}
-+[class*="_footArea"]>[class*="_footerActions"]{flex:1 1 auto;min-width:0}
-+[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important}
-+[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important}
-+[class*="_widthHandle"]{display:none!important}
-+
-+.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out}
-+.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center}
-+.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px}
-+.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear}
-+.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8}
-+.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite}
-+.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite}
-+@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}
-+@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}}
-+.bt-voice-orb>.bt-mark{position:relative}
-+.bt-voice-name{font-size:16px;line-height:22px;font-weight:500}
-+.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px}
-+.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
-+.bt-voice-note{font-size:12px;color:#e02135}
-+.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease}
-+.bt-voice-setup:hover{background:var(--bt-active)}
-+.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px}
-+.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease}
-+.bt-voice-btn:hover{background:var(--bt-active)}
-+.bt-voice-btn:active{transform:scale(.94)}
-+.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)}
-+.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)}
-+.bt-voice-end{background:#ff3e51;color:#fff}
-+.bt-voice-end:hover{background:#e02135}
-+@keyframes bt-fade{from{opacity:0}to{opacity:1}}
-+@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}}
-+@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}}
-+.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))}
-+/* The bubble's row: reactions hang below it, so the hover toolbar (and a group
-+ sender's avatar) line up with the bubble alone. */
-+.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%}
-+.bt-msg-line>.bt-bubble{max-width:none;min-width:0}
-+/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */
-+.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))}
-+.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s}
-+.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1}
-+.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible}
-+.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease}
-+.bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)}
-+.bt-tool:active{background:var(--bt-active)}
-+.bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px}
+ shell offers them and CSS-module name suffixes elsewhere. */
+ [class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important}
+ [class*="_footArea"]{display:flex!important;flex-direction:row-reverse;align-items:center;gap:9px;padding:8px 6px 6px!important}
+@@ -2361,7 +2361,7 @@ button.bt-set-row:hover{background:var(--bt-hover)}
+ .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)}
+ .bt-tool:active{background:var(--bt-active)}
+ .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px}
+-.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out}
+.bt-react-strip{position:fixed;z-index:130;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out}
-+.bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
-+.bt-react-strip button:nth-child(2){animation-delay:20ms}
-+.bt-react-strip button:nth-child(3){animation-delay:40ms}
-+.bt-react-strip button:nth-child(4){animation-delay:60ms}
-+.bt-react-strip button:nth-child(n+5){animation-delay:80ms}
-+.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)}
-+.bt-react-strip button:active{transform:scale(.92)}
-+.bt-reacts{display:flex;gap:4px;margin-top:4px}
-+.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}}
-+.bt-react:hover{background:var(--bt-hover)}
-+.bt-react:active{scale:.92}
-+@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}}
-+.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none}
-+.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease}
-+button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))}
-+.bt-you-wrap{display:inline-flex;padding:0}
-+.bt-connect-foot{width:100%;height:36px}
-+
+ .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
+ .bt-react-strip button:nth-child(2){animation-delay:20ms}
+ .bt-react-strip button:nth-child(3){animation-delay:40ms}
+@@ -2380,7 +2380,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side
+ .bt-you-wrap{display:inline-flex;padding:0}
+ .bt-connect-foot{width:100%;height:36px}
+
+-.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box}
+.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box}
-+.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box}
-+.bt-menu button:hover{background:var(--bt-hover)}
-+.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px}
-+.bt-menu .bt-danger{color:#c21d2e}
-+.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px}
-+.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in}
-+.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards}
-+.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums}
+ .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box}
+ .bt-menu button:hover{background:var(--bt-hover)}
+ .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px}
+@@ -2390,7 +2390,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side
+ .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards}
+ .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums}
+-.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:140;isolation:isolate;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
-+.bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out}
-+.bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
-+.bt-palette-layer[data-leaving]{pointer-events:none}
-+.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in}
-+.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards}
-+@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}}
-+.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)}
-+.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px}
-+.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0}
-+.bt-palette-head input::placeholder{color:var(--bt-ink-3)}
-+.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
-+.bt-palette-clear:hover{color:var(--bt-ink)}
-+.bt-palette-clear svg{width:12px;height:12px}
-+.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box}
-+.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)}
-+.bt-palette-list>.bt-thumb{left:8px;right:8px}
-+:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent}
-+:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1}
-+.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none}
-+.bt-palette-section:first-of-type{padding-top:0}
-+.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%}
-+.bt-palette-row[aria-selected=true]{background:var(--bt-active)}
-+.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center}
-+.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
-+.bt-palette-cmd svg{width:14px;height:14px}
-+.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)}
-+.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
-+.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)}
-+.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out}
-+.bt-palette-empty-icon{color:var(--bt-line-2)}
-+.bt-palette-empty-icon svg{width:32px;height:32px}
-+.bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)}
-+.bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)}
-+@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}}
+ .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out}
+ .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+ .bt-palette-layer[data-leaving]{pointer-events:none}
+@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side
+ .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)}
+ .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)}
+ @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}}
+-.bt-account-menu{width:224px}
+.bt-account-menu{min-width:224px;width:max-content;max-width:calc(100vw - 16px)}
+.bt-flybot-account-header:not(:empty){padding:6px 0 2px;margin-bottom:4px;border-bottom:.5px solid var(--bt-line-2)}
-+.bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex}
-+.bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))}
-+/* Popover keyframes move translate and scale, which compose with a transform the popover
-+ already has. */
-+@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}}
-+@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}}
-+@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}}
-+@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}}
-+@keyframes bt-check-pop{from{opacity:0;scale:.4}}
-+@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}}
-+.bt-brand:hover{background:var(--bt-hover)}
-+.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px}
-+.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px}
-+.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0}
-+.bt-pick:hover{background:var(--bt-hover)}
-+.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)}
-+.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1}
-+.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px}
-+.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px}
-+.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px}
-+.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box}
-+.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)}
-+.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0}
-+.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)}
-+.bt-states{display:flex;flex-wrap:wrap;gap:4px}
-+.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer}
-+.bt-state:hover{color:var(--bt-ink)}
-+.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
-+.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden}
-+.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)}
-+body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)}
-+body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)}
-+.bt-tabs{flex-wrap:wrap}
-+.bt-soft:disabled{opacity:.4;cursor:default}
-+.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px}
-+.bt-members{display:flex;flex-direction:column;gap:1px}
-+.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out}
-+.bt-member:hover{background:var(--bt-hover)}
-+.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left}
-+.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent}
-+.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer}
-+.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)}
-+.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease}
-+.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1}
-+.bt-add{color:var(--bt-ink-2);margin-top:2px}
-+.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)}
-+.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out}
-+.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)}
-+.bt-motion-seg{width:216px}
-+.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}}
-+.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)}
-+.bt-seg button:disabled{opacity:.4;cursor:default}
-+.bt-seg button:active:not(:disabled){scale:.96}
-+.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)}
-+.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none}
-+@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
-+@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}}
-+
+ .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex}
+ .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))}
+ /* Popover keyframes move translate and scale, which compose with a transform the popover
+@@ -2488,3021 +2488,6777 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)}
+ @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}}
+ @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}}
+
+-/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */
+-.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease}
+-.bt-mem-row:hover{background:var(--bt-hover)}
+-.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)}
+-.bt-mem-row-copy{flex:1;text-align:left}
+-.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px}
+-.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+-.bt-mem-scrim{animation:bt-fade .2s ease-out}
+-.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+-.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box}
+-.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none}
+-.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6}
+-.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px}
+-.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap}
+-.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums}
+-.bt-mem-head-gap{flex:1}
+-.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right}
+-.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none}
+-.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px}
+-.bt-mem-summary{transition:opacity .3s ease}
+-.bt-mem-summary[data-stale]{opacity:.55}
+-.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600}
+-.bt-mem-summary h3:first-child{margin-top:0}
+-.bt-mem-summary p{margin:0;font-size:15px;line-height:26px}
+-.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both}
+-.bt-mem-summary>:nth-child(2){animation-delay:.03s}
+-.bt-mem-summary>:nth-child(3){animation-delay:.06s}
+-.bt-mem-summary>:nth-child(4){animation-delay:.09s}
+-.bt-mem-summary>:nth-child(5){animation-delay:.12s}
+-.bt-mem-summary>:nth-child(6){animation-delay:.15s}
+-.bt-mem-summary>:nth-child(n+7){animation-delay:.18s}
+-@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}}
+-.bt-mem-skel{display:flex;flex-direction:column;gap:24px}
+-.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px}
+-.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite}
+-.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px}
+-@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}}
+-.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)}
+-.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both}
+-.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px}
+-.bt-mem-empty-mark svg{width:22px;height:22px}
+-.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600}
+-.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)}
+-.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+-.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere}
+-.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out}
+-.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
+-.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600}
+-.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+-.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap}
+-.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500}
+-.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
+-.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease}
+-.bt-mem-entry[data-busy]{opacity:.4}
+-.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
+-.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere}
+-.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
+-.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease}
+-.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1}
+-.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)}
+-.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px}
+-@media (hover:none){.bt-mem-remove{opacity:1}}
+-.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px}
+-.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px}
+-.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+-.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px}
+-.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none}
+-.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere}
+-.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px}
+-.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere}
+-.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards}
+-.bt-mem-changes li[data-ok] svg{color:var(--bt-green)}
+-.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease}
+-.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)}
+-.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0}
+-.bt-mem-input::placeholder{color:var(--bt-ink-3)}
+-.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}}
+-.bt-mem-send:disabled{opacity:.22;cursor:default}
+-.bt-mem-send:not(:disabled):active{scale:.92}
+-.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+-.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite}
+-@keyframes bt-mem-spin{to{transform:rotate(360deg)}}
+-@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}}
+-@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}}
+/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */
+.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease}
+.bt-mem-row:hover{background:var(--bt-hover)}
@@ -6356,127 +6030,25 @@ diff --git a/client.js b/client.js
+.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards}
+.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap}
- @keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}}
- @keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}}
- @keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}}
-@@ -2676,2833 +2676,6541 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%}
- [data-bt-enter="new"]{animation:none}
- [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out}
- }
--@keyframes bt-fade-in{from{opacity:0}to{opacity:1}}
--@keyframes bt-fade-out{from{opacity:1}to{opacity:0}}
--[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important}
--[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important}
--[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important}
--[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important}
--[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important}
--[data-composer-card]>[class*="_row"]{display:contents!important}
--[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0}
--[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important}
--[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0}
--[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%}
--[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important}
--[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important}
--[data-composer-placeholder]{font-size:0!important;line-height:0!important}
--[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)}
--/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */
--[data-composer-card] button[class*="_add"]{display:none!important}
--.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}}
--.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)}
--.bt-plus:active{scale:.9}
--.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}}
--.bt-plus[aria-expanded=true] svg{rotate:45deg}
--.bt-plus-menu{min-width:180px}
--[data-composer-card] [class*="_tools"]{gap:0!important}
--[data-composer-card] [class*="_modes"]:empty{display:none!important}
--[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important}
--[data-composer-card] [class*="_trailing"]{gap:8px}
--[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px}
--[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
--[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9}
--[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9}
--@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}}
--[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px}
--[data-composer-card] button[class*="_primary"]:disabled{display:none!important}
--[data-composer-dock]{display:none!important}
--.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}}
--.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)}
--.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)}
--.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite}
--@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}}
--.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
--.bt-voice:hover{opacity:.9}
--.bt-mic:active,.bt-voice:active{scale:.9}
--[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid}
--body[data-bt-room] .bt-voice{display:none!important}
--body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)}
--.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out}
--[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2}
--.bt-q{order:1}
--div:has(> nav [class*="_marks"]){display:none!important}
--/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies)
-- takes its surface and its text from the one --bt-user pair: a skin that repaints
-- the host's bubble surface alone would otherwise leave the text unreadable. */
--[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important}
--[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit}
--/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */
--[class*="_userRow"] [class*="_actions"]{display:none!important}
--[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start}
--[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none}
--[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible}
--[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none}
--[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)}
--[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)}
--[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px}
--.bt-q-inline{display:flex;margin:4px 0 6px}
--[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important}
--[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important}
--.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0}
--.bt-q-dock{width:100%;display:flex;justify-content:center}
--.bt-q-dock .bt-q{margin:0 auto 8px}
--[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important}
--[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important}
--[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}}
--[class*="_toBottomSlot"]>button:active{scale:.9}
--[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important}
--[class*="_toBottomSlot"]>button svg{display:none}
--[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat}
--@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}}
--[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important}
--.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out}
--@keyframes bt-news-in{from{translate:0 8px;scale:.9}}
--.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in}
--@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}}
--.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none}
--.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px}
--.bt-news-ico svg{width:16px;height:16px}
--.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
--.bt-news-x svg{width:12px;height:12px}
--.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)}
--@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}}
--.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
--.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)}
--.bt-open-new{display:flex;flex-direction:column}
--.bt-open-new .bt-new-sep{margin:18px 0 0}
--.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px}
--.bt-earlier{display:flex;flex-direction:column;gap:6px}
--.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none}
--.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px}
--.bt-urow{display:flex;justify-content:flex-end}
--.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}
--.bt-uimages{display:block;font-size:12px;opacity:.7}
--.bt-earlier .bt-event{margin:4px 0}
--.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
--.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)}
--`;
--function installStyles(text, tag) {
-- const style = document.createElement("style");
-- style.dataset.dshBot = tag;
-- style.textContent = text;
-- document.head.appendChild(style);
-- return style;
++@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}}
++@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}}
++@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}}
++/* Shimmer: a stepped sweep clipped to the text. */
++.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)}
++@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}
++/* New rows slide up; a reply that replaces the activity row grows out of its corner. */
++[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
++[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards}
++@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}}
++@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}}
++@media (prefers-reduced-motion:reduce){
++.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none}
++.bt-typing[data-exiting]{animation-name:bt-fade-out}
++.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s}
++.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)}
++[data-bt-enter="new"]{animation:none}
++[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out}
++}
+@keyframes bt-fade-in{from{opacity:0}to{opacity:1}}
+@keyframes bt-fade-out{from{opacity:1}to{opacity:0}}
+[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important}
@@ -6890,7 +6462,108 @@ diff --git a/client.js b/client.js
+ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] }
+ })));
+}
-+
+
+-.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease}
+-.bt-thumb[data-hidden]{opacity:0}
+-[data-morph]{overflow:hidden!important}
+-.bt-veil{animation:bt-veil-in .22s ease-out}
+-.bt-with-icon{display:inline-flex;align-items:center;gap:4px}
+-.bt-with-icon svg{width:14px;height:14px}
+-.bt-tag-admin{display:inline-flex;align-items:center;gap:4px}
+-.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
+-.bt-create-inline .bt-create-card{max-width:none}
+-.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards}
+-.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards}
+-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms}
+-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms}
+-.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out}
+-.bt-drawer-name-input{animation:bt-veil-in .16s ease-out}
+-.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+-:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}}
+-:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9}
+-:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96}
+-@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}}
+-`;
+-var CONVERSATION_CSS = `
+-.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)}
+-.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px}
+-.bt-think summary::-webkit-details-marker{display:none}
+-.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
+-.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap}
+-.bt-event>span{padding:0 2px}
+-.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease}
+-.bt-event button:hover{background:var(--bt-hover)}
+-.bt-event button:active{background:var(--bt-active)}
+-.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px}
+-.bt-event-whole>*{flex:none}
+-.bt-event-whole>span:first-child{margin-right:2px}
+-/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */
+-.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap}
+-[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap}
+-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)}
+-.bt-group-replies{display:contents}
+-/* A Bot chat is flat in every work-details mode, without changing the user's mode
+- (it is a DSH-wide setting): step groups lose their header and stay open, and what a
+- mode folds away in a finished turn stays shown. TurnProcessCell also opens that
+- fold; these rules cover the first frame before it does. */
+-[data-step-process]>div:first-child{display:none!important}
+-[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important}
+-:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important}
+-[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important}
+-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important}
+-/* The transcript runs under a floating header pill. */
+-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center}
+-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1}
+-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto}
+-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%}
+-[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)}
+-/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators),
+- without its copy and branch row; a tail with nothing in it takes no room. */
+-[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important}
+-[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important}
+-/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not
+- the shell's new-session hero: no headline, no workspace or mode picker, and the
+- composer stays docked at the bottom. */
+-[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important}
+-[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important}
+-[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important}
+-[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important}
+-/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */
+-[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important}
+-/* The shell's running status only hosts the activity row (.bt-typing). */
+-[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important}
+-[data-chat-running]>span:not([role=status]){display:none!important}
+-[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important}
+-.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards}
+-.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards}
+-.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0}
+-.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out}
+-.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))}
+-.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)}
+-.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18}
+-.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards}
+-.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards}
+-.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap}
+-@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}}
+-@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}}
+-@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}}
+-/* Shimmer: a stepped sweep clipped to the text. */
+-.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)}
+-@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}
+-/* New rows slide up; a reply that replaces the activity row grows out of its corner. */
+-[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
+-[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards}
+-@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}}
+-@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}}
+-@media (prefers-reduced-motion:reduce){
+-.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none}
+-.bt-typing[data-exiting]{animation-name:bt-fade-out}
+-.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s}
+-.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)}
+-[data-bt-enter="new"]{animation:none}
+-[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out}
+// src/client/secret-card.js
+var import_react9 = require("react");
+var MIN_VALUE = 4;
@@ -7915,120 +7588,242 @@ diff --git a/client.js b/client.js
+ String(words ?? "").trim() === "" ? null : (0, import_react11.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }),
+ files.length ? (0, import_react11.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null
+ );
-+}
-+function FirstContact({ source, roster, sessionId }) {
-+ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : "";
-+ const who = person || remoteSpeaker(source, roster).name;
-+ const bot = roster.byId[sessionId]?.name ?? t("your Bot");
-+ return (0, import_react11.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react11.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot })));
-+}
-+function GlobeMark() {
-+ return (0, import_react11.createElement)(
-+ "svg",
-+ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true },
-+ (0, import_react11.createElement)("circle", { cx: 12, cy: 12, r: 9 }),
-+ (0, import_react11.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" })
-+ );
-+}
-+function RemoteFiles({ files, plugin, botId }) {
-+ const [busy, setBusy] = (0, import_react11.useState)("");
-+ const [error, setError] = (0, import_react11.useState)("");
-+ const download2 = async (file) => {
-+ setBusy(file.id);
-+ setError("");
-+ try {
-+ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId });
-+ const value = got?.value ?? got;
-+ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0));
-+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
-+ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name });
-+ link.click();
-+ setTimeout(() => URL.revokeObjectURL(url), 1e4);
-+ } catch (reason) {
-+ setError(String(reason?.message ?? reason));
-+ } finally {
-+ setBusy("");
-+ }
-+ };
-+ return (0, import_react11.createElement)(
-+ "div",
-+ { className: "bt-file-chips" },
-+ files.map((file) => (0, import_react11.createElement)(
-+ "button",
-+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) },
-+ `📎 ${file.name} · ${fileSize(file.size)}`
-+ )),
-+ error ? (0, import_react11.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null
-+ );
-+}
-+function GroupEvent({ prefix, room: room2, name, roster, actions }) {
-+ return (0, import_react11.createElement)(
-+ "div",
-+ { className: "bt-event" },
-+ (0, import_react11.createElement)("span", null, prefix),
-+ room2 ? (0, import_react11.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react11.createElement)("span", null, name) : null
-+ );
-+}
-+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) {
-+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null;
-+ const self = roster.byId[sessionId];
-+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim());
-+ const other = team || !scope ? void 0 : findBot(scope);
-+ const target = other && other.id !== self?.id ? other : self;
-+ if (!target) return null;
-+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name });
-+ return (0, import_react11.createElement)(
-+ "div",
-+ { className: "bt-event" },
-+ (0, import_react11.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react11.createElement)(MemoryIcon), label)
-+ );
-+}
-+function PartLine({ time }) {
-+ return (0, import_react11.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react11.createElement)("span", null, dayLabel(time)) : null);
-+}
-+var scrollParent = (node) => {
-+ for (let at = node.parentElement; at; at = at.parentElement) {
-+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at;
-+ }
-+ return null;
-+};
-+function EarlierParts({ sessionId, roster, actions, useUi }) {
-+ const [view, setView] = (0, import_react11.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null });
-+ const top = (0, import_react11.useRef)(null);
-+ const busy = (0, import_react11.useRef)(false);
-+ const alive = (0, import_react11.useRef)(true);
-+ const restore = (0, import_react11.useRef)(null);
-+ const load = (cursor) => {
-+ if (busy.current) return;
-+ busy.current = true;
-+ setView((current) => ({ ...current, loading: true, failed: false }));
-+ actions.earlier(sessionId, cursor).then((page) => {
-+ busy.current = false;
-+ if (!alive.current) return;
-+ const scroller = top.current && scrollParent(top.current);
-+ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop };
-+ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt }));
-+ }, () => {
-+ busy.current = false;
-+ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true }));
-+ });
-+ };
-+ (0, import_react11.useEffect)(() => {
-+ alive.current = true;
-+ load(void 0);
-+ return () => {
-+ alive.current = false;
-+ };
-+ }, [sessionId]);
-+ (0, import_react11.useLayoutEffect)(() => {
-+ const pending = restore.current;
-+ restore.current = null;
-+ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd;
-+ }, [view.items]);
-+ (0, import_react11.useEffect)(() => {
-+ const node = top.current;
-+ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0;
-+ const observer = new IntersectionObserver((entries2) => {
+ }
+-@keyframes bt-fade-in{from{opacity:0}to{opacity:1}}
+-@keyframes bt-fade-out{from{opacity:1}to{opacity:0}}
+-[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important}
+-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important}
+-[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important}
+-[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important}
+-[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important}
+-[data-composer-card]>[class*="_row"]{display:contents!important}
+-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0}
+-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important}
+-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0}
+-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%}
+-[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important}
+-[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important}
+-[data-composer-placeholder]{font-size:0!important;line-height:0!important}
+-[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)}
+-/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */
+-[data-composer-card] button[class*="_add"]{display:none!important}
+-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}}
+-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)}
+-.bt-plus:active{scale:.9}
+-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}}
+-.bt-plus[aria-expanded=true] svg{rotate:45deg}
+-.bt-plus-menu{min-width:180px}
+-[data-composer-card] [class*="_tools"]{gap:0!important}
+-[data-composer-card] [class*="_modes"]:empty{display:none!important}
+-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important}
+-[data-composer-card] [class*="_trailing"]{gap:8px}
+-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px}
+-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9}
+-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9}
+-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}}
+-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px}
+-[data-composer-card] button[class*="_primary"]:disabled{display:none!important}
+-[data-composer-dock]{display:none!important}
+-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}}
+-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)}
+-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)}
+-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite}
+-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}}
+-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+-.bt-voice:hover{opacity:.9}
+-.bt-mic:active,.bt-voice:active{scale:.9}
+-[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid}
+-body[data-bt-room] .bt-voice{display:none!important}
+-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)}
+-.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out}
+-[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2}
+-.bt-q{order:1}
+-div:has(> nav [class*="_marks"]){display:none!important}
+-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies)
+- takes its surface and its text from the one --bt-user pair: a skin that repaints
+- the host's bubble surface alone would otherwise leave the text unreadable. */
+-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important}
+-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit}
+-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */
+-[class*="_userRow"] [class*="_actions"]{display:none!important}
+-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start}
+-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none}
+-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible}
+-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none}
+-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)}
+-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)}
+-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px}
+-.bt-q-inline{display:flex;margin:4px 0 6px}
+-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important}
+-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important}
+-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0}
+-.bt-q-dock{width:100%;display:flex;justify-content:center}
+-.bt-q-dock .bt-q{margin:0 auto 8px}
+-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important}
+-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important}
+-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}}
+-[class*="_toBottomSlot"]>button:active{scale:.9}
+-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important}
+-[class*="_toBottomSlot"]>button svg{display:none}
+-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat}
+-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}}
+-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important}
+-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out}
+-@keyframes bt-news-in{from{translate:0 8px;scale:.9}}
+-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in}
+-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}}
+-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none}
+-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px}
+-.bt-news-ico svg{width:16px;height:16px}
+-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
+-.bt-news-x svg{width:12px;height:12px}
+-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)}
+-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}}
+-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)}
+-.bt-open-new{display:flex;flex-direction:column}
+-.bt-open-new .bt-new-sep{margin:18px 0 0}
+-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px}
+-.bt-earlier{display:flex;flex-direction:column;gap:6px}
+-.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none}
+-.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px}
+-.bt-urow{display:flex;justify-content:flex-end}
+-.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}
+-.bt-uimages{display:block;font-size:12px;opacity:.7}
+-.bt-earlier .bt-event{margin:4px 0}
+-.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
+-.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)}
+-`;
+-function installStyles(text, tag) {
+- const style = document.createElement("style");
+- style.dataset.dshBot = tag;
+- style.textContent = text;
+- document.head.appendChild(style);
+- return style;
++function FirstContact({ source, roster, sessionId }) {
++ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : "";
++ const who = person || remoteSpeaker(source, roster).name;
++ const bot = roster.byId[sessionId]?.name ?? t("your Bot");
++ return (0, import_react11.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react11.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot })));
+ }
+-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g;
+-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g;
+-function sheetClassNames(sheet) {
+- const names = [];
+- const visit = (rules) => {
+- for (const rule of rules) {
+- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]);
+- if (rule.cssRules) visit(rule.cssRules);
++function GlobeMark() {
++ return (0, import_react11.createElement)(
++ "svg",
++ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true },
++ (0, import_react11.createElement)("circle", { cx: 12, cy: 12, r: 9 }),
++ (0, import_react11.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" })
++ );
++}
++function RemoteFiles({ files, plugin, botId }) {
++ const [busy, setBusy] = (0, import_react11.useState)("");
++ const [error, setError] = (0, import_react11.useState)("");
++ const download2 = async (file) => {
++ setBusy(file.id);
++ setError("");
++ try {
++ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId });
++ const value = got?.value ?? got;
++ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0));
++ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
++ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name });
++ link.click();
++ setTimeout(() => URL.revokeObjectURL(url), 1e4);
++ } catch (reason) {
++ setError(String(reason?.message ?? reason));
++ } finally {
++ setBusy("");
++ }
++ };
++ return (0, import_react11.createElement)(
++ "div",
++ { className: "bt-file-chips" },
++ files.map((file) => (0, import_react11.createElement)(
++ "button",
++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) },
++ `📎 ${file.name} · ${fileSize(file.size)}`
++ )),
++ error ? (0, import_react11.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null
++ );
++}
++function GroupEvent({ prefix, room: room2, name, roster, actions }) {
++ return (0, import_react11.createElement)(
++ "div",
++ { className: "bt-event" },
++ (0, import_react11.createElement)("span", null, prefix),
++ room2 ? (0, import_react11.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react11.createElement)("span", null, name) : null
++ );
++}
++function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) {
++ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null;
++ const self = roster.byId[sessionId];
++ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim());
++ const other = team || !scope ? void 0 : findBot(scope);
++ const target = other && other.id !== self?.id ? other : self;
++ if (!target) return null;
++ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name });
++ return (0, import_react11.createElement)(
++ "div",
++ { className: "bt-event" },
++ (0, import_react11.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react11.createElement)(MemoryIcon), label)
++ );
++}
++function PartLine({ time }) {
++ return (0, import_react11.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react11.createElement)("span", null, dayLabel(time)) : null);
++}
++var scrollParent = (node) => {
++ for (let at = node.parentElement; at; at = at.parentElement) {
++ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at;
++ }
++ return null;
++};
++function EarlierParts({ sessionId, roster, actions, useUi }) {
++ const [view, setView] = (0, import_react11.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null });
++ const top = (0, import_react11.useRef)(null);
++ const busy = (0, import_react11.useRef)(false);
++ const alive = (0, import_react11.useRef)(true);
++ const restore = (0, import_react11.useRef)(null);
++ const load = (cursor) => {
++ if (busy.current) return;
++ busy.current = true;
++ setView((current) => ({ ...current, loading: true, failed: false }));
++ actions.earlier(sessionId, cursor).then((page) => {
++ busy.current = false;
++ if (!alive.current) return;
++ const scroller = top.current && scrollParent(top.current);
++ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop };
++ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt }));
++ }, () => {
++ busy.current = false;
++ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true }));
++ });
++ };
++ (0, import_react11.useEffect)(() => {
++ alive.current = true;
++ load(void 0);
++ return () => {
++ alive.current = false;
++ };
++ }, [sessionId]);
++ (0, import_react11.useLayoutEffect)(() => {
++ const pending = restore.current;
++ restore.current = null;
++ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd;
++ }, [view.items]);
++ (0, import_react11.useEffect)(() => {
++ const node = top.current;
++ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0;
++ const observer = new IntersectionObserver((entries2) => {
+ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor);
+ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" });
+ observer.observe(node);
@@ -8122,7 +7917,11 @@ diff --git a/client.js b/client.js
+ if (source.remote?.handle) {
+ const message = (0, import_react11.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi });
+ return source.remote.firstContact === true ? (0, import_react11.createElement)(import_react11.Fragment, null, (0, import_react11.createElement)(FirstContact, { source, roster, sessionId }), message) : message;
-+ }
+ }
+- };
+- try {
+- visit(sheet.cssRules);
+- } catch {
+ if (source.kind === "bot") {
+ if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react11.createElement)(EarlierParts, { sessionId, roster, actions, useUi });
+ if (source.role === "room" || source.role === "kickoff") return null;
@@ -8166,8 +7965,18 @@ diff --git a/client.js b/client.js
+ }
+ if (source.kind === "user-question-reply") return null;
+ return (0, import_react11.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update"));
-+ }
-+}
+ }
+- return names;
+ }
+-function compileFragmentSelectors(text, names) {
+- const compiled = /* @__PURE__ */ new Map();
+- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => {
+- if (!compiled.has(fragment)) {
+- const exact = [];
+- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`);
+- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole);
+- }
+- return compiled.get(fragment);
+function NewDivider() {
+ return (0, import_react11.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react11.createElement)("span", null, t("New")));
+}
@@ -8186,12 +7995,84 @@ diff --git a/client.js b/client.js
+ const fresh = useTurnClock((state) => {
+ const own = state[sessionId]?.[turn];
+ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered;
-+ });
+ });
+ return fresh ? (0, import_react11.createElement)(NewDivider) : null;
-+}
+ }
+-function installCompiledStyles(text, tag) {
+- const style = installStyles("", tag);
+- const cache = /* @__PURE__ */ new WeakMap();
+- const render = () => {
+- const names = /* @__PURE__ */ new Set();
+- for (const sheet of document.styleSheets) {
+- if (sheet.ownerNode?.dataset?.dshBot) continue;
+- let list = cache.get(sheet);
+- if (!list) {
+- list = sheetClassNames(sheet);
+- cache.set(sheet, list);
+- }
+- for (const name of list) names.add(name);
+- }
+- const next = compileFragmentSelectors(text, names);
+- if (style.textContent !== next) style.textContent = next;
+- };
+- render();
+- let timer;
+- const schedule = () => {
+- clearTimeout(timer);
+- timer = setTimeout(render, 120);
+- };
+- 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();
+- };
+function Nothing() {
+ return null;
-+}
+ }
+-
+-// src/client/mark.js
+-var import_react2 = require("react");
+-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"];
+-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle";
+-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working";
+-function useGaze(ref, enabled) {
+- (0, import_react2.useEffect)(() => {
+- if (!enabled) return void 0;
+- let frame = 0;
+- let point = null;
+- const place = () => {
+- frame = 0;
+- const node = ref.current;
+- if (!node || !point) return;
+- const box = node.getBoundingClientRect();
+- const clamp = (value) => Math.max(-1, Math.min(1, value));
+- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2));
+- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2));
+- };
+- const onMove = (event) => {
+- point = { x: event.clientX, y: event.clientY };
+- if (!frame) frame = requestAnimationFrame(place);
+- };
+- const onLeave = () => {
+- point = null;
+- ref.current?.style.setProperty("--bt-gx", "0");
+- ref.current?.style.setProperty("--bt-gy", "0");
+- };
+- window.addEventListener("pointermove", onMove, { passive: true });
+- document.documentElement.addEventListener("pointerleave", onLeave);
+- return () => {
+- cancelAnimationFrame(frame);
+- window.removeEventListener("pointermove", onMove);
+- document.documentElement.removeEventListener("pointerleave", onLeave);
+- };
+- }, [enabled]);
+var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]);
+var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name);
+var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([
@@ -8247,7 +8128,36 @@ diff --git a/client.js b/client.js
+ }
+ if (parts.length === 0) return null;
+ return (0, import_react11.createElement)("div", { className: "bt-event" }, parts.join(" · "));
-+}
+ }
+-var FIDGETS = ["hop", "sway", "stretch", "look"];
+-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) {
+- const look = given ?? lookOf(bot);
+- const ref = (0, import_react2.useRef)(null);
+- const maskId = (0, import_react2.useRef)("");
+- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`;
+- const [poked, setPoked] = (0, import_react2.useState)(false);
+- useGaze(ref, gaze);
+- const common = {
+- ref,
+- className: poked ? "bt-mark bt-mark-poke" : "bt-mark",
+- "data-shape": look.image ? "image" : shapeId(look.shape),
+- "data-state": STATES.includes(state) ? state : "idle",
+- "data-live": live ? "" : void 0,
+- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0,
+- "data-gaze": gaze ? "" : void 0,
+- "data-poke": pokeable ? "" : void 0,
+- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} },
+- role: label ? "img" : void 0,
+- "aria-label": label,
+- "aria-hidden": label ? void 0 : true,
+- onClick: pokeable ? () => setPoked(true) : void 0,
+- onAnimationEnd: poked ? (event) => {
+- if (/^bt-poke/.test(event.animationName)) setPoked(false);
+- } : void 0
+- };
+- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false }));
+- const box = shapeOf(look.shape).size;
+- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } }));
+var SEPARATOR_GAP_MS = 30 * 60 * 1e3;
+function clockLabel(time, now = Date.now()) {
+ const date = new Date(time);
@@ -8309,15 +8219,7 @@ diff --git a/client.js b/client.js
+ label ? (0, import_react11.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null,
+ fresh ? (0, import_react11.createElement)(NewDivider) : null
+ );
- }
--var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g;
--var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g;
--function sheetClassNames(sheet) {
-- const names = [];
-- const visit = (rules) => {
-- for (const rule of rules) {
-- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]);
-- if (rule.cssRules) visit(rule.cssRules);
++}
+function Shimmer({ children }) {
+ return (0, import_react11.createElement)("span", { className: "bt-shimmer" }, children);
+}
@@ -8350,11 +8252,7 @@ diff --git a/client.js b/client.js
+ case "message_bot": {
+ const target = findBot(arg("to"));
+ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" };
- }
-- };
-- try {
-- visit(sheet.cssRules);
-- } catch {
++ }
+ case "stop_bot": {
+ const target = findBot(arg("bot"));
+ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" };
@@ -8381,89 +8279,29 @@ diff --git a/client.js b/client.js
+ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" };
+ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1];
+ return { label: phrase ? t(phrase) : null, state: toolState(name) };
- }
-- return names;
++ }
}
--function compileFragmentSelectors(text, names) {
-- const compiled = /* @__PURE__ */ new Map();
-- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => {
-- if (!compiled.has(fragment)) {
-- const exact = [];
-- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`);
-- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole);
-- }
-- return compiled.get(fragment);
-- });
+-function StarIcon({ size = 8 }) {
+- return (0, import_react2.createElement)(
+- "svg",
+- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true },
+- (0, import_react2.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 workPhrase(tool) {
+ if (!tool) return t("Thinking about it");
+ return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool });
}
--function installCompiledStyles(text, tag) {
-- const style = installStyles("", tag);
-- const cache = /* @__PURE__ */ new WeakMap();
-- const render = () => {
-- const names = /* @__PURE__ */ new Set();
-- for (const sheet of document.styleSheets) {
-- if (sheet.ownerNode?.dataset?.dshBot) continue;
-- let list = cache.get(sheet);
-- if (!list) {
-- list = sheetClassNames(sheet);
-- cache.set(sheet, list);
-- }
-- for (const name of list) names.add(name);
-- }
-- const next = compileFragmentSelectors(text, names);
-- if (style.textContent !== next) style.textContent = next;
-- };
-- render();
-- let timer;
-- const schedule = () => {
-- clearTimeout(timer);
-- timer = setTimeout(render, 120);
-- };
-- 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();
-- };
+-function AdminStar() {
+- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 }));
+function workText(tool, step) {
+ return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`;
}
--
--// src/client/mark.js
--var import_react2 = require("react");
--var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"];
--var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle";
--var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working";
--function useGaze(ref, enabled) {
-- (0, import_react2.useEffect)(() => {
-- if (!enabled) return void 0;
-- let frame = 0;
-- let point = null;
-- const place = () => {
-- frame = 0;
-- const node = ref.current;
-- if (!node || !point) return;
-- const box = node.getBoundingClientRect();
-- const clamp = (value) => Math.max(-1, Math.min(1, value));
-- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2));
-- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2));
-- };
-- const onMove = (event) => {
-- point = { x: event.clientX, y: event.clientY };
-- if (!frame) frame = requestAnimationFrame(place);
-- };
-- const onLeave = () => {
-- point = null;
-- ref.current?.style.setProperty("--bt-gx", "0");
-- ref.current?.style.setProperty("--bt-gy", "0");
+-function MainStar() {
+- return (0, import_react2.createElement)(
+- "svg",
+- { viewBox: "0 0 20 20", "aria-hidden": true },
+- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" })
+- );
+function useLiveNote(actions, sessionId, id, entry) {
+ const key = entry ? JSON.stringify(entry) : "";
+ (0, import_react11.useEffect)(() => {
@@ -8471,7 +8309,16 @@ diff --git a/client.js b/client.js
+ actions.noteLive(sessionId, id, JSON.parse(key));
+ return () => actions.noteLive(sessionId, id, null);
+ }, [sessionId, id, key]);
-+}
+ }
+-function useSettled(state) {
+- const prev = (0, import_react2.useRef)(state);
+- const [settled, setSettled] = (0, import_react2.useState)(false);
+- (0, import_react2.useEffect)(() => {
+- const was = prev.current;
+- prev.current = state;
+- if (state !== "idle") {
+- setSettled(false);
+- return void 0;
+function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) {
+ const [live, setLive] = (0, import_react11.useState)({ replies: [] });
+ (0, import_react11.useEffect)(() => {
@@ -8489,73 +8336,78 @@ diff --git a/client.js b/client.js
+ setLive({ replies: entry.replies ?? [] });
+ }
+ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3);
- };
-- window.addEventListener("pointermove", onMove, { passive: true });
-- document.documentElement.addEventListener("pointerleave", onLeave);
++ };
+ void tick();
- return () => {
-- cancelAnimationFrame(frame);
-- window.removeEventListener("pointermove", onMove);
-- document.documentElement.removeEventListener("pointerleave", onLeave);
++ return () => {
+ stop = true;
+ clearTimeout(timer);
- };
-- }, [enabled]);
++ };
++ }, [done, sessionId, callId]);
++ let replies = live.replies;
++ if (done) {
++ try {
++ replies = JSON.parse(contentText(content)).replies ?? [];
++ } catch {
++ replies = [];
+ }
+- if (was === "idle" || was === "alert") return void 0;
+- setSettled(true);
+- const timer = setTimeout(() => setSettled(false), 1800);
+- return () => clearTimeout(timer);
+- }, [state]);
+- return settled;
-}
--var FIDGETS = ["hop", "sway", "stretch", "look"];
--function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) {
-- const look = given ?? lookOf(bot);
-- const ref = (0, import_react2.useRef)(null);
-- const maskId = (0, import_react2.useRef)("");
-- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`;
-- const [poked, setPoked] = (0, import_react2.useState)(false);
-- useGaze(ref, gaze);
-- const common = {
-- ref,
-- className: poked ? "bt-mark bt-mark-poke" : "bt-mark",
-- "data-shape": look.image ? "image" : shapeId(look.shape),
-- "data-state": STATES.includes(state) ? state : "idle",
-- "data-live": live ? "" : void 0,
-- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0,
-- "data-gaze": gaze ? "" : void 0,
-- "data-poke": pokeable ? "" : void 0,
-- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} },
-- role: label ? "img" : void 0,
-- "aria-label": label,
-- "aria-hidden": label ? void 0 : true,
-- onClick: pokeable ? () => setPoked(true) : void 0,
-- onAnimationEnd: poked ? (event) => {
-- if (/^bt-poke/.test(event.animationName)) setPoked(false);
-- } : void 0
-- };
-- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false }));
-- const box = shapeOf(look.shape).size;
-- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } }));
--}
--function StarIcon({ size = 8 }) {
+-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)(
-- "svg",
-- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true },
-- (0, import_react2.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" })
+- "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 AdminStar() {
-- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 }));
+-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]];
+- }
+- const half = frame / 2;
+- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]];
-}
--function MainStar() {
+-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
+- );
+ }
+- 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)(
-- "svg",
-- { viewBox: "0 0 20 20", "aria-hidden": true },
-- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" })
-+ }, [done, sessionId, callId]);
-+ let replies = live.replies;
-+ if (done) {
-+ try {
-+ replies = JSON.parse(contentText(content)).replies ?? [];
-+ } catch {
-+ replies = [];
-+ }
-+ }
+- "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}`);
+ if (replies.length === 0) return null;
+ const room2 = roster.roomsById[sessionId];
+ return (0, import_react11.createElement)(
@@ -8598,7 +8450,9 @@ diff --git a/client.js b/client.js
+ }
+ if (reply.kind === "missed") {
+ return (0, import_react11.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name }));
-+ }
+ }
+- const bot = people[index];
+- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
+ if (reply.kind === "resumed") {
+ return (0, import_react11.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name }));
+ }
@@ -8639,25 +8493,29 @@ diff --git a/client.js b/client.js
+ });
+ }
+ return (0, import_react11.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 });
-+ })
+ })
);
}
--function useSettled(state) {
-- const prev = (0, import_react2.useRef)(state);
-- const [settled, setSettled] = (0, import_react2.useState)(false);
-- (0, import_react2.useEffect)(() => {
-- const was = prev.current;
-- prev.current = state;
-- if (state !== "idle") {
-- setSettled(false);
-- return void 0;
-- }
-- if (was === "idle" || was === "alert") return void 0;
-- setSettled(true);
-- const timer = setTimeout(() => setSettled(false), 1800);
-- return () => clearTimeout(timer);
-- }, [state]);
-- return settled;
+-
+-// src/client/icons.js
+-var import_react3 = require("react");
+-function PlusIcon() {
+- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
+-}
+-function SearchIcon() {
+- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
+-}
+-function PeopleIcon() {
+- return (0, import_react3.createElement)(
+- "svg",
+- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true },
+- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }),
+- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }),
+- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" })
+- );
+-}
+-function ArrowUpIcon() {
+- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }));
+var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]);
+var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]);
+function callTree(root) {
@@ -8673,18 +8531,12 @@ diff --git a/client.js b/client.js
+ visit(root);
+ return calls;
}
--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 MicIcon({ size = 16 }) {
+- return (0, import_react3.createElement)(
+- "svg",
+- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true },
+- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }),
+- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" })
- );
+function useShownCards(actions, sessionId, callIds) {
+ const key = JSON.stringify(callIds);
@@ -8695,13 +8547,12 @@ diff --git a/client.js b/client.js
+ return () => actions.noteCards(sessionId, ids, -1);
+ }, [sessionId, key]);
}
--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 WaveIcon({ size = 16 }) {
+- return (0, import_react3.createElement)(
+- "svg",
+- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
+- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 }))
+- );
+function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) {
+ const tool = toolName(call4);
+ const done = call4.kind === "tool-result";
@@ -8777,40 +8628,13 @@ diff --git a/client.js b/client.js
+ return done && !call4.isError ? (0, import_react11.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null;
+ default:
+ return null;
- }
-- const half = frame / 2;
-- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]];
++ }
}
--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
-- );
-- }
-- 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));
-- })
+-function ShareIcon() {
+- return (0, import_react3.createElement)(
+- "svg",
+- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" })
- );
+function ToolCell({ node, sessionId, useRoster, useUi, actions }) {
+ const roster = useRoster((value) => value);
@@ -8828,26 +8652,9 @@ diff --git a/client.js b/client.js
+ const env = { sessionId, roster, actions, useUi, findBot };
+ return (0, import_react11.createElement)(import_react11.Fragment, null, drawn.map((call4, index) => (0, import_react11.createElement)(import_react11.Fragment, { key: call4.callId ?? index }, renderCall(call4, env))));
}
-
--// src/client/icons.js
--var import_react3 = require("react");
--function PlusIcon() {
-- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
--}
--function SearchIcon() {
-- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
--}
--function PeopleIcon() {
-- return (0, import_react3.createElement)(
-- "svg",
-- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true },
-- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }),
-- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }),
-- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" })
-- );
--}
--function ArrowUpIcon() {
-- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" }));
+-function CloseIcon() {
+- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
++
+// src/client/awaiting.js
+var AWAIT_REPLY_MS = 10 * 60 * 1e3;
+function awaitingReply(roster, sessionId, now = Date.now()) {
@@ -8855,12 +8662,22 @@ diff --git a/client.js b/client.js
+ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false;
+ return now - at < AWAIT_REPLY_MS;
}
--function MicIcon({ size = 16 }) {
+-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" }));
+-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }));
+-var lineIcon = (paths) => function LineIcon() {
- return (0, import_react3.createElement)(
- "svg",
-- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true },
-- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }),
-- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" })
+- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d }))
+- );
+-};
+-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5";
+-function CloudDownloadIcon({ done = false }) {
+- return (0, import_react3.createElement)(
+- "svg",
+- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+- (0, import_react3.createElement)("path", { d: CLOUD }),
+- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" }))
- );
+
+// src/client/remote-approval-wait.js
@@ -8870,85 +8687,6 @@ diff --git a/client.js b/client.js
+ if (tool && inputAt <= (tool.at ?? 0)) return null;
+ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" };
}
--function WaveIcon({ size = 16 }) {
-- return (0, import_react3.createElement)(
-- "svg",
-- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
-- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 }))
-- );
-+
-+// src/client/activity-row-host.js
-+var RUNNING_HOST = "[data-chat-flow] [data-chat-running]";
-+var TAIL_HOST = "tail";
-+function findRunningHostIn(root) {
-+ if (!root || typeof root.querySelector !== "function") return null;
-+ if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root;
-+ if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]");
-+ return root.querySelector(RUNNING_HOST);
- }
--function ShareIcon() {
-- return (0, import_react3.createElement)(
-- "svg",
-- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
-- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" })
-- );
-+
-+// src/client/activity-row.js
-+var import_react12 = require("react");
-+var import_react_dom2 = require("react-dom");
-+var PHRASE_HOLD_MS = 800;
-+var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]);
-+function elapsedLabel(ms) {
-+ const minutes = Math.floor(ms / 6e4);
-+ if (minutes < 60) return `${Math.max(1, minutes)}m`;
-+ const hours = Math.floor(minutes / 60);
-+ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`;
- }
--function CloseIcon() {
-- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" }));
-+var REMOTE_GRACE_MS = 1500;
-+function useAwaitingReply(roster, sessionId) {
-+ const at = roster?.remoteAwaiting?.[sessionId];
-+ const [, tick] = (0, import_react12.useState)(0);
-+ const shown = awaitingReply(roster, sessionId);
-+ (0, import_react12.useEffect)(() => {
-+ if (!shown) return void 0;
-+ const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50);
-+ return () => clearTimeout(timer);
-+ }, [shown, at]);
-+ return shown;
- }
--var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" }));
--var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" }));
--var lineIcon = (paths) => function LineIcon() {
-- return (0, import_react3.createElement)(
-- "svg",
-- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
-- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d }))
-- );
-+function findHost(where) {
-+ if (where !== TAIL_HOST) return findRunningHostIn(document);
-+ const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]");
-+ if (!flow) return null;
-+ const running = findRunningHostIn(flow);
-+ if (running) return running;
-+ const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]");
-+ return tails.length ? tails[tails.length - 1] : null;
-+}
-+var lastTurnOnScreen = (kind) => {
-+ let top = -1;
-+ for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn));
-+ return top;
- };
--var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5";
--function CloudDownloadIcon({ done = false }) {
-- return (0, import_react3.createElement)(
-- "svg",
-- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
-- (0, import_react3.createElement)("path", { d: CLOUD }),
-- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" }))
-- );
--}
-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]);
-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]);
-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]);
@@ -8975,7 +8713,16 @@ diff --git a/client.js b/client.js
- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" })
- )
- );
--}
++
++// src/client/activity-row-host.js
++var RUNNING_HOST = "[data-chat-flow] [data-chat-running]";
++var TAIL_HOST = "tail";
++function findRunningHostIn(root) {
++ if (!root || typeof root.querySelector !== "function") return null;
++ if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root;
++ if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]");
++ return root.querySelector(RUNNING_HOST);
+ }
-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]);
-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]);
-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]);
@@ -8989,7 +8736,7 @@ diff --git a/client.js b/client.js
-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]);
-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]);
-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon };
--
+
-// src/client/questions.js
-var import_react4 = require("react");
-var keyLetter = (index) => String.fromCharCode(65 + index);
@@ -9011,6 +8758,61 @@ diff --git a/client.js b/client.js
- } else if (index === count && ownRef.current) {
- event.preventDefault();
- ownRef.current.focus();
+- }
+- };
+- document.addEventListener("keydown", onKey, true);
+- return () => document.removeEventListener("keydown", onKey, true);
+- }, [count, ownRef]);
++// src/client/activity-row.js
++var import_react12 = require("react");
++var import_react_dom2 = require("react-dom");
++var PHRASE_HOLD_MS = 800;
++var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]);
++function elapsedLabel(ms) {
++ const minutes = Math.floor(ms / 6e4);
++ if (minutes < 60) return `${Math.max(1, minutes)}m`;
++ const hours = Math.floor(minutes / 60);
++ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`;
+ }
+-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) {
+- const [own, setOwn] = (0, import_react4.useState)("");
+- const [picked, setPicked] = (0, import_react4.useState)([]);
+- const ownRef = (0, import_react4.useRef)(null);
+- const titleId = (0, import_react4.useId)();
+- const options = optionsOf(question);
+- const multi = question.multiSelect === true;
+- const custom = own.trim();
+- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== "";
+- const choose = (label) => {
+- if (multi) {
+- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]);
+- return;
++var REMOTE_GRACE_MS = 1500;
++function useAwaitingReply(roster, sessionId) {
++ const at = roster?.remoteAwaiting?.[sessionId];
++ const [, tick] = (0, import_react12.useState)(0);
++ const shown = awaitingReply(roster, sessionId);
++ (0, import_react12.useEffect)(() => {
++ if (!shown) return void 0;
++ const timer = setTimeout(() => tick((n) => n + 1), Math.max(0, at + AWAIT_REPLY_MS - Date.now()) + 50);
++ return () => clearTimeout(timer);
++ }, [shown, at]);
++ return shown;
++}
++function findHost(where) {
++ if (where !== TAIL_HOST) return findRunningHostIn(document);
++ const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]");
++ if (!flow) return null;
++ const running = findRunningHostIn(flow);
++ if (running) return running;
++ const tails = flow.querySelectorAll(":scope > * [data-turn-tail], :scope > [data-turn-tail]");
++ return tails.length ? tails[tails.length - 1] : null;
++}
++var lastTurnOnScreen = (kind) => {
++ let top = -1;
++ for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn));
++ return top;
++};
+function useReplyAfterSend(remote, sending) {
+ const sendTurn = (0, import_react12.useRef)(-1);
+ const wasSending = (0, import_react12.useRef)(false);
@@ -9022,11 +8824,107 @@ diff --git a/client.js b/client.js
+ sendTurn.current = -1;
+ setReplied(false);
+ return void 0;
-+ }
+ }
+- onAnswer({ selected: [label] });
+- };
+- const submit = () => {
+- if (!canSubmit) return;
+- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} });
+- };
+- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef);
+- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0;
+- const field = ownAnswer ? (0, import_react4.createElement)("textarea", {
+- ref: ownRef,
+- rows: 1,
+- value: own,
+- autoComplete: "off",
+- spellCheck: false,
+- className: "bt-q-input",
+- "aria-label": t("Your own answer"),
+- placeholder: t("Type your own answer"),
+- "aria-keyshortcuts": ownLetter?.toLowerCase(),
+- onChange: (event) => setOwn(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
+- event.preventDefault();
+- submit();
+ if (wasSending.current) {
+ wasSending.current = false;
+ sendTurn.current = lastTurnOnScreen();
-+ }
+ }
+- }) : null;
+- const rows = options.map((option, optionIndex) => {
+- const selected = multi && picked.includes(option.label);
+- return (0, import_react4.createElement)(
+- "button",
+- {
+- key: option.label,
+- type: "button",
+- className: "bt-q-opt",
+- "aria-pressed": multi ? selected : void 0,
+- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(),
+- onClick: () => choose(option.label)
+- },
+- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)),
+- (0, import_react4.createElement)(
+- "span",
+- { className: "bt-q-body" },
+- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label),
+- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null
+- ),
+- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null
+- );
+- });
+- if (raised && field) {
+- rows.push((0, import_react4.createElement)(
+- "label",
+- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 },
+- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null,
+- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field)
+- ));
+- }
+- let foot = null;
+- if (raised && multi) {
+- foot = (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q-foot" },
+- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")),
+- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit"))
+- );
+- } else if ((raised || multi) && canSubmit) {
+- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")));
+- }
+- return (0, import_react4.createElement)(
+- "form",
+- {
+- className: raised ? "bt-q bt-q-raised" : "bt-q",
+- "aria-labelledby": titleId,
+- onSubmit: (event) => {
+- event.preventDefault();
+- submit();
+- }
+- },
+- (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q-head" },
+- (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q-text" },
+- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question),
+- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null,
+- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null
+- ),
+- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon))
+- ),
+- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null,
+- field && !raised ? (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q-custom" },
+- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field),
+- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null
+- ) : null,
+- foot
+- );
+ if (sendTurn.current < 0) return void 0;
+ let frame = 0;
+ const check = () => {
@@ -9044,27 +8942,58 @@ diff --git a/client.js b/client.js
+ };
+ }, [remote, sending]);
+ return replied;
-+}
-+function useRunningHost(active, where = RUNNING_HOST) {
-+ const [host, setHost] = (0, import_react12.useState)(null);
-+ (0, import_react12.useEffect)(() => {
-+ if (!active) {
-+ setHost(null);
-+ return void 0;
-+ }
-+ let frame = 0;
-+ const find = () => {
-+ frame = 0;
-+ const node = findHost(where);
-+ setHost((prev) => prev === node ? prev : node);
-+ };
-+ const observer = new MutationObserver(() => {
-+ if (!frame) frame = requestAnimationFrame(find);
-+ });
-+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
-+ find();
-+ return () => {
-+ cancelAnimationFrame(frame);
+ }
+-function SettledQuestion({ question, answer }) {
+- const options = optionsOf(question);
+- const prompt = question?.question ?? "";
+- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []];
+- if (chosen.length === 0) {
+- return (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt },
+- (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q-head" },
+- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)),
+- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed"))
+- )
+- );
+- }
+- return (0, import_react4.createElement)(
+- "div",
+- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt },
+- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null,
+- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => {
+- const optionIndex = options.findIndex((option) => option.label === label);
+- return (0, import_react4.createElement)(
+- "div",
+- { key: `${label}-${index}`, className: "bt-q-opt" },
+- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null,
+- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)),
+- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon))
+- );
+- }))
+- );
++function useRunningHost(active, where = RUNNING_HOST) {
++ const [host, setHost] = (0, import_react12.useState)(null);
++ (0, import_react12.useEffect)(() => {
++ if (!active) {
++ setHost(null);
++ return void 0;
++ }
++ let frame = 0;
++ const find = () => {
++ frame = 0;
++ const node = findHost(where);
++ setHost((prev) => prev === node ? prev : node);
++ };
++ const observer = new MutationObserver(() => {
++ if (!frame) frame = requestAnimationFrame(find);
++ });
++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
++ find();
++ return () => {
++ cancelAnimationFrame(frame);
+ observer.disconnect();
+ };
+ }, [active, where]);
@@ -9105,11 +9034,8 @@ diff --git a/client.js b/client.js
+ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind);
+ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new";
+ item.addEventListener("animationend", settle);
- }
- };
-- document.addEventListener("keydown", onKey, true);
-- return () => document.removeEventListener("keydown", onKey, true);
-- }, [count, ownRef]);
++ }
++ };
+ const observer = new MutationObserver(() => {
+ if (!frame) frame = requestAnimationFrame(scan);
+ });
@@ -9120,18 +9046,18 @@ diff --git a/client.js b/client.js
+ };
+ }, []);
}
--function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) {
-- const [own, setOwn] = (0, import_react4.useState)("");
-- const [picked, setPicked] = (0, import_react4.useState)([]);
-- const ownRef = (0, import_react4.useRef)(null);
-- const titleId = (0, import_react4.useId)();
-- const options = optionsOf(question);
-- const multi = question.multiSelect === true;
-- const custom = own.trim();
-- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== "";
-- const choose = (label) => {
-- if (multi) {
-- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]);
+-function QuestionCard({ matched }) {
+- const pending = matched;
+- const questions = pending.questions ?? [];
+- const [index, setIndex] = (0, import_react4.useState)(0);
+- const [answers, setAnswers] = (0, import_react4.useState)([]);
+- const question = questions[index];
+- if (question === void 0) return null;
+- const onAnswer = (answer) => {
+- const next = [...answers, { id: question.id, ...answer }];
+- if (index + 1 < questions.length) {
+- setAnswers(next);
+- setIndex(index + 1);
- return;
+function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) {
+ const roster = useRoster((value) => value);
@@ -9172,29 +9098,22 @@ diff --git a/client.js b/client.js
+ make();
+ return void 0;
}
-- onAnswer({ selected: [label] });
+- pending.answer({ answers: next });
- };
-- const submit = () => {
-- if (!canSubmit) return;
-- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} });
+- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true });
+-}
+-function PendingQuestion({ sessionId, question, actions, inline }) {
+- const [answered, setAnswered] = (0, import_react4.useState)(null);
+- if (answered === question.id) return null;
+- const onAnswer = (answer) => {
+- setAnswered(question.id);
+- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null));
- };
-- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef);
-- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0;
-- const field = ownAnswer ? (0, import_react4.createElement)("textarea", {
-- ref: ownRef,
-- rows: 1,
-- value: own,
-- autoComplete: "off",
-- spellCheck: false,
-- className: "bt-q-input",
-- "aria-label": t("Your own answer"),
-- placeholder: t("Type your own answer"),
-- "aria-keyshortcuts": ownLetter?.toLowerCase(),
-- onChange: (event) => setOwn(event.target.value),
-- onKeyDown: (event) => {
-- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
-- event.preventDefault();
-- submit();
+- const onDismiss = () => {
+- setAnswered(question.id);
+- void actions.dismissQuestion(sessionId);
+- };
+- return (0, import_react4.createElement)(
+ const timer = setTimeout(make, wait);
+ return () => clearTimeout(timer);
+ }, [wantKey, shown]);
@@ -9207,22 +9126,7 @@ diff --git a/client.js b/client.js
+ if (visible) {
+ setPhase("in");
+ return void 0;
- }
-- }) : null;
-- const rows = options.map((option, optionIndex) => {
-- const selected = multi && picked.includes(option.label);
-- return (0, import_react4.createElement)(
-- "button",
-- {
-- key: option.label,
-- type: "button",
-- className: "bt-q-opt",
-- "aria-pressed": multi ? selected : void 0,
-- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(),
-- onClick: () => choose(option.label)
-- },
-- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)),
-- (0, import_react4.createElement)(
++ }
+ let timer;
+ const leave = () => {
+ setPhase((current) => current === "in" ? "out" : current);
@@ -9266,143 +9170,43 @@ diff --git a/client.js b/client.js
+ if (!host || phase === null || !shown) return null;
+ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null;
+ return (0, import_react_dom2.createPortal)((0, import_react12.createElement)(
-+ "div",
+ "div",
+- { className: inline ? "bt-q-inline" : "bt-q-dock" },
+- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline })
+- );
+-}
+-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) {
+- const question = useRoster((value) => value.questions?.[sessionId]);
+- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true;
+- const loading = useSession?.(historyLoading) === true;
+- if (question === void 0 || question.callId && (drawn || loading)) return null;
+- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false });
+ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true },
+ bot ? (0, import_react12.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
+ (0, import_react12.createElement)(
+ "span",
+ { className: "bt-typing-text" },
+ (0, import_react12.createElement)(
- "span",
-- { className: "bt-q-body" },
-- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label),
-- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null
-- ),
-- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null
-- );
-- });
-- if (raised && field) {
-- rows.push((0, import_react4.createElement)(
-- "label",
-- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 },
-- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null,
-- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field)
-- ));
-- }
-- let foot = null;
-- if (raised && multi) {
-- foot = (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q-foot" },
-- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")),
-- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit"))
-- );
-- } else if ((raised || multi) && canSubmit) {
-- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")));
-- }
-- return (0, import_react4.createElement)(
-- "form",
-- {
-- className: raised ? "bt-q bt-q-raised" : "bt-q",
-- "aria-labelledby": titleId,
-- onSubmit: (event) => {
-- event.preventDefault();
-- submit();
-- }
-- },
-- (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q-head" },
-- (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q-text" },
-- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question),
-- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null,
-- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null
-- ),
-- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon))
-- ),
-- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null,
-- field && !raised ? (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q-custom" },
-- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field),
-- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null
-- ) : null,
-- foot
-- );
--}
--function SettledQuestion({ question, answer }) {
-- const options = optionsOf(question);
-- const prompt = question?.question ?? "";
-- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []];
-- if (chosen.length === 0) {
-- return (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt },
-- (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q-head" },
-- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)),
-- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed"))
++ "span",
+ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" },
+ (0, import_react12.createElement)(Shimmer, null, `${shown.text}…`),
+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null
- )
-- );
++ )
++ )
++ ), host);
+ }
+-function AnsweredCell({ node }) {
+- const data = node.data ?? {};
+- const questions = Array.isArray(data.questions) ? data.questions : [];
+- if (questions.length === 0) {
+- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null;
- }
-- return (0, import_react4.createElement)(
-- "div",
-- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt },
-- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null,
-- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => {
-- const optionIndex = options.findIndex((option) => option.label === label);
-- return (0, import_react4.createElement)(
-- "div",
-- { key: `${label}-${index}`, className: "bt-q-opt" },
-- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null,
-- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)),
-- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon))
-- );
-- }))
-- );
--}
--function QuestionCard({ matched }) {
-- const pending = matched;
-- const questions = pending.questions ?? [];
-- const [index, setIndex] = (0, import_react4.useState)(0);
-- const [answers, setAnswers] = (0, import_react4.useState)([]);
-- const question = questions[index];
-- if (question === void 0) return null;
-- const onAnswer = (answer) => {
-- const next = [...answers, { id: question.id, ...answer }];
-- if (index + 1 < questions.length) {
-- setAnswers(next);
-- setIndex(index + 1);
-- return;
-- }
-- pending.answer({ answers: next });
-- };
-- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true });
-+ )
-+ ), host);
- }
--function PendingQuestion({ sessionId, question, actions, inline }) {
-- const [answered, setAnswered] = (0, import_react4.useState)(null);
-- if (answered === question.id) return null;
-- const onAnswer = (answer) => {
-- setAnswered(question.id);
-- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null));
-- };
-- const onDismiss = () => {
-- setAnswered(question.id);
-- void actions.dismissQuestion(sessionId);
-- };
-- return (0, import_react4.createElement)(
-- "div",
-- { className: inline ? "bt-q-inline" : "bt-q-dock" },
-- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline })
-- );
+- const answers = Array.isArray(data.answers) ? data.answers : [];
+- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, {
+- key: question.id,
+- question,
+- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] }
+- })));
+
+// src/client/dsh-offer.js
+var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]);
@@ -9413,63 +9217,6 @@ diff --git a/client.js b/client.js
+ if (dsh.kind === "npm") return true;
+ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified);
}
--function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) {
-- const question = useRoster((value) => value.questions?.[sessionId]);
-- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true;
-- const loading = useSession?.(historyLoading) === true;
-- if (question === void 0 || question.callId && (drawn || loading)) return null;
-- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false });
-+
-+// src/client/overlay-hooks.js
-+var import_react13 = require("react");
-+var escapeLayers = [];
-+var onEscapeKey = (event) => {
-+ if (event.key === "Escape") escapeLayers.at(-1)?.current();
-+};
-+function useEscape(onClose) {
-+ const ref = (0, import_react13.useRef)(onClose);
-+ ref.current = onClose;
-+ (0, import_react13.useEffect)(() => {
-+ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey);
-+ escapeLayers.push(ref);
-+ return () => {
-+ escapeLayers.splice(escapeLayers.indexOf(ref), 1);
-+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey);
-+ };
-+ }, []);
- }
--function AnsweredCell({ node }) {
-- const data = node.data ?? {};
-- const questions = Array.isArray(data.questions) ? data.questions : [];
-- if (questions.length === 0) {
-- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null;
-- }
-- const answers = Array.isArray(data.answers) ? data.answers : [];
-- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, {
-- key: question.id,
-- question,
-- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] }
-- })));
-+function useDialogFocus(ref) {
-+ (0, import_react13.useEffect)(() => {
-+ const before = document.activeElement;
-+ ref.current?.focus?.({ preventScroll: true });
-+ return () => {
-+ const active = document.activeElement;
-+ const stillInDialog = active === ref.current || ref.current?.contains?.(active) === true;
-+ const noFocusHeld = !active || active === document.body || active.isConnected === false;
-+ if (before && typeof before.focus === "function" && before.isConnected && (stillInDialog || noFocusHeld || active === before)) before.focus({ preventScroll: true });
-+ };
-+ }, []);
-+}
-+function usePaneLeft() {
-+ const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280);
-+ (0, import_react13.useLayoutEffect)(() => {
-+ const column = document.querySelector('[class*="_footArea"]')?.parentElement;
-+ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right));
-+ }, []);
-+ return paneLeft;
- }
-// src/client/secret-card.js
-var import_react5 = require("react");
@@ -9499,13 +9246,11 @@ diff --git a/client.js b/client.js
- if (scope === "all") return t("All Bots");
- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean);
- return names.length === 0 ? t("No Bot") : names.join(t(" and "));
-+// src/client/member-status.js
-+var import_react14 = require("react");
-+var NO_WORK = { bots: {}, rooms: {}, approvals: [] };
-+var ROWS_MAX = 4;
-+var stepIn = (work, botId, roomId, tool) => {
-+ const own = work.bots?.[botId];
-+ return own?.roomId === roomId && own.tool === tool ? own.step : void 0;
++// src/client/overlay-hooks.js
++var import_react13 = require("react");
++var escapeLayers = [];
++var onEscapeKey = (event) => {
++ if (event.key === "Escape") escapeLayers.at(-1)?.current();
};
-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) {
@@ -9531,19 +9276,18 @@ diff --git a/client.js b/client.js
- event.preventDefault();
- onEnter?.();
- }
-+var workIn = (room2, work, id) => {
-+ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id);
-+ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) };
-+ const own = work.bots?.[id];
-+ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0;
-+};
-+var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true));
-+function memberWork(room2, work = NO_WORK) {
-+ return room2.members.map((id) => {
-+ const doing = workIn(room2, work, id);
-+ const approvals = askedIn(room2, work, id, doing);
-+ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" };
- });
+- });
++function useEscape(onClose) {
++ const ref = (0, import_react13.useRef)(onClose);
++ ref.current = onClose;
++ (0, import_react13.useEffect)(() => {
++ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey);
++ escapeLayers.push(ref);
++ return () => {
++ escapeLayers.splice(escapeLayers.indexOf(ref), 1);
++ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey);
++ };
++ }, []);
}
-function ScopeSwitch({ value, onChange, bot }) {
- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]];
@@ -9560,7 +9304,18 @@ diff --git a/client.js b/client.js
- onClick: () => onChange(id)
- }, text))
- );
--}
++function useDialogFocus(ref) {
++ (0, import_react13.useEffect)(() => {
++ const before = document.activeElement;
++ ref.current?.focus?.({ preventScroll: true });
++ return () => {
++ const active = document.activeElement;
++ const stillInDialog = active === ref.current || ref.current?.contains?.(active) === true;
++ const noFocusHeld = !active || active === document.body || active.isConnected === false;
++ if (before && typeof before.focus === "function" && before.isConnected && (stillInDialog || noFocusHeld || active === before)) before.focus({ preventScroll: true });
++ };
++ }, []);
+ }
-function SecretHead({ bot, title, detail, name, onClose }) {
- return (0, import_react5.createElement)(
- "div",
@@ -9574,9 +9329,13 @@ diff --git a/client.js b/client.js
- ),
- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null
- );
-+function busyMembers(room2, work, roster) {
-+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]);
-+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] }));
++function usePaneLeft() {
++ const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280);
++ (0, import_react13.useLayoutEffect)(() => {
++ const column = document.querySelector('[class*="_footArea"]')?.parentElement;
++ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right));
++ }, []);
++ return paneLeft;
}
-function SecretCard({ bot, request, roster, actions }) {
- const [value, setValue] = (0, import_react5.useState)("");
@@ -9636,10 +9395,28 @@ diff --git a/client.js b/client.js
- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow"))
- )
- );
-+function splitBusy(busy) {
-+ const here = busy.filter((entry) => entry.state === "work" && entry.here);
-+ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX);
-+ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) };
++
++// src/client/member-status.js
++var import_react14 = require("react");
++var NO_WORK = { bots: {}, rooms: {}, approvals: [] };
++var ROWS_MAX = 4;
++var stepIn = (work, botId, roomId, tool) => {
++ const own = work.bots?.[botId];
++ return own?.roomId === roomId && own.tool === tool ? own.step : void 0;
++};
++var workIn = (room2, work, id) => {
++ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id);
++ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) };
++ const own = work.bots?.[id];
++ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0;
++};
++var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true));
++function memberWork(room2, work = NO_WORK) {
++ return room2.members.map((id) => {
++ const doing = workIn(room2, work, id);
++ const approvals = askedIn(room2, work, id, doing);
++ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" };
++ });
}
-function SettledSecret({ bot, request, roster }) {
- const canceled = request.status === "canceled";
@@ -9660,24 +9437,16 @@ diff --git a/client.js b/client.js
- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon))
- )
- );
-+function botGroupWork(botId, work, roster) {
-+ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => {
-+ const entry = entries2.find((item) => item.botId === botId);
-+ const room2 = roster.roomsById[roomId];
-+ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : [];
-+ });
++function busyMembers(room2, work, roster) {
++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]);
++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] }));
}
-function RequestView({ bot, request, roster, actions }) {
- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster });
-+var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count });
-+function summaryText(busy) {
-+ const asks = busy.filter((entry) => entry.state === "ask");
-+ const workers = busy.filter((entry) => entry.state === "work");
-+ const names = asks.slice(0, 2).map((entry) => entry.bot.name);
-+ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length });
-+ const first = workers[0];
-+ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : "";
-+ return { waiting, doing };
++function splitBusy(busy) {
++ const here = busy.filter((entry) => entry.state === "work" && entry.here);
++ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX);
++ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) };
}
-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) {
- const bot = roster.byId[sessionId];
@@ -9688,12 +9457,12 @@ diff --git a/client.js b/client.js
- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") })));
- }
- return null;
-+function Face({ entry, size }) {
-+ return (0, import_react14.createElement)(
-+ "span",
-+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 },
-+ (0, import_react14.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") })
-+ );
++function botGroupWork(botId, work, roster) {
++ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => {
++ const entry = entries2.find((item) => item.botId === botId);
++ const room2 = roster.roomsById[roomId];
++ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : [];
++ });
}
-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) {
- const roster = useRoster((value) => value);
@@ -9705,17 +9474,15 @@ diff --git a/client.js b/client.js
- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry));
- if (waiting.length === 0) return null;
- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions })));
-+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : "";
-+function WorkRow({ bot, tool, step, label, at, now, onOpen }) {
-+ const text2 = `${label} · ${workText(tool, step)}`;
-+ const elapsed = elapsedSince(now, at);
-+ return (0, import_react14.createElement)(
-+ "button",
-+ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen },
-+ (0, import_react14.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }),
-+ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react14.createElement)(Shimmer, null, text2)),
-+ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null
-+ );
++var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count });
++function summaryText(busy) {
++ const asks = busy.filter((entry) => entry.state === "ask");
++ const workers = busy.filter((entry) => entry.state === "work");
++ const names = asks.slice(0, 2).map((entry) => entry.bot.name);
++ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length });
++ const first = workers[0];
++ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : "";
++ return { waiting, doing };
}
-function ScopeEditor({ secret, roster, onSave }) {
- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some");
@@ -9735,20 +9502,7 @@ diff --git a/client.js b/client.js
- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }),
- text
- ))
-+function SummaryLine({ busy, now, onOpen }) {
-+ const { waiting, doing } = summaryText(busy);
-+ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : "";
-+ return (0, import_react14.createElement)(
-+ "button",
-+ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen },
-+ (0, import_react14.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react14.createElement)(Face, { key: entry.id, entry, size: 16 }))),
-+ (0, import_react14.createElement)(
-+ "span",
-+ { className: "bt-mw-row-text" },
-+ waiting ? (0, import_react14.createElement)("span", { className: "bt-mw-ask" }, waiting) : null,
-+ waiting && doing ? " · " : null,
-+ doing ? (0, import_react14.createElement)(Shimmer, null, doing) : null
- ),
+- ),
- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", {
- key: bot.id,
- type: "button",
@@ -9761,7 +9515,11 @@ diff --git a/client.js b/client.js
- { className: "bt-secret-row-actions" },
- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access"))
- )
-+ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null
++function Face({ entry, size }) {
++ return (0, import_react14.createElement)(
++ "span",
++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 },
++ (0, import_react14.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") })
);
}
-function ValueEditor({ secret, onSave }) {
@@ -9782,57 +9540,26 @@ diff --git a/client.js b/client.js
- setReplaced(false);
- };
- return (0, import_react5.createElement)(
-+function MemberDock({ sessionId, useRoster, useWork, actions }) {
-+ const roster = useRoster((value) => value);
-+ const work = useWork((value) => value);
-+ const room2 = roster.roomsById[sessionId];
-+ const bot = room2 ? void 0 : roster.byId[sessionId];
-+ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0;
-+ const busy = room2 ? busyMembers(room2, work, roster) : [];
-+ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : [];
-+ const shown = busy.length > 0 || groups.length > 0;
-+ const now = useNow(shown);
-+ const ref = (0, import_react14.useRef)(null);
-+ useFlowColumn(ref, shown, sessionId);
-+ if (!shown) return null;
-+ if (room2) {
-+ const { rows: rows2, rest } = splitBusy(busy);
-+ const open = () => actions.showDetails(sessionId);
-+ return (0, import_react14.createElement)(
-+ "div",
-+ { ref, className: "bt-mw-dock-stack" },
-+ rows2.map((entry) => (0, import_react14.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })),
-+ rest.length > 0 ? (0, import_react14.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null
-+ );
-+ }
-+ const rows = groups.slice(0, ROWS_MAX);
-+ const more = groups.length - rows.length;
-+ return (0, import_react14.createElement)(
- "div",
+- "div",
- { className: "bt-secret-edit-block" },
- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")),
- (0, import_react5.createElement)(
-+ { ref, className: "bt-mw-dock-stack" },
-+ rows.map((item) => (0, import_react14.createElement)(WorkRow, {
-+ key: item.room.id,
-+ bot,
-+ tool: item.tool,
-+ step: item.step,
-+ label: t("in {name}", { name: item.room.name }),
-+ at: item.since,
-+ now,
-+ onOpen: () => actions.openSession(item.room.id)
-+ })),
-+ more > 0 ? (0, import_react14.createElement)(
- "div",
+- "div",
- { className: "bt-secret-inline" },
- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })),
- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace"))
- ),
- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null
-+ { className: "bt-mw-row bt-mw-line bt-mw-static" },
-+ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more))
-+ ) : null
++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : "";
++function WorkRow({ bot, tool, step, label, at, now, onOpen }) {
++ const text2 = `${label} · ${workText(tool, step)}`;
++ const elapsed = elapsedSince(now, at);
++ return (0, import_react14.createElement)(
++ "button",
++ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen },
++ (0, import_react14.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }),
++ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react14.createElement)(Shimmer, null, text2)),
++ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null
);
}
-function SecretRow({ secret, roster, actions, run, open, onToggle }) {
@@ -9844,45 +9571,7 @@ diff --git a/client.js b/client.js
- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean);
- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · ");
- return (0, import_react5.createElement)(
-+function useFlowColumn(ref, active, key) {
-+ (0, import_react14.useLayoutEffect)(() => {
-+ const node = ref.current;
-+ const flow = document.querySelector("[data-chat-flow]");
-+ if (!active || !node || !flow) return void 0;
-+ const place = () => {
-+ node.style.marginInlineStart = "0px";
-+ const column = flow.getBoundingClientRect();
-+ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`;
-+ node.style.width = `${Math.round(column.width)}px`;
-+ };
-+ place();
-+ const observer = new ResizeObserver(place);
-+ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target);
-+ return () => observer.disconnect();
-+ }, [active, key]);
-+}
-+function useNow(active) {
-+ const [now, setNow] = (0, import_react14.useState)(Date.now);
-+ (0, import_react14.useEffect)(() => {
-+ if (!active) return void 0;
-+ const timer = setInterval(() => setNow(Date.now()), 3e4);
-+ return () => clearInterval(timer);
-+ }, [active]);
-+ return now;
-+}
-+var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : "";
-+var alwaysChoices = (approval, bot) => bot?.remote ? [] : [
-+ ["always-bot", "Always allow for this Bot"],
-+ ...approval.roomId ? [["always-room", "Always allow for this group"]] : []
-+];
-+function ApprovalCard({ approval, bot, actions }) {
-+ const [busy, setBusy] = (0, import_react14.useState)(false);
-+ const answer = (choice) => {
-+ setBusy(true);
-+ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false));
-+ };
-+ return (0, import_react14.createElement)(
- "div",
+- "div",
- { className: "bt-secret-item", "data-open": open ? "" : void 0 },
- (0, import_react5.createElement)(
- "button",
@@ -9898,15 +9587,22 @@ diff --git a/client.js b/client.js
- { className: "bt-set-control" },
- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false })))
- )
-- ),
++function SummaryLine({ busy, now, onOpen }) {
++ const { waiting, doing } = summaryText(busy);
++ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : "";
++ return (0, import_react14.createElement)(
++ "button",
++ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen },
++ (0, import_react14.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react14.createElement)(Face, { key: entry.id, entry, size: 16 }))),
++ (0, import_react14.createElement)(
++ "span",
++ { className: "bt-mw-row-text" },
++ waiting ? (0, import_react14.createElement)("span", { className: "bt-mw-ask" }, waiting) : null,
++ waiting && doing ? " · " : null,
++ doing ? (0, import_react14.createElement)(Shimmer, null, doing) : null
+ ),
- open ? (0, import_react5.createElement)(
-+ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) },
-+ (0, import_react14.createElement)("div", { className: "bt-mw-tool" }, approval.tool),
-+ approval.detail?.text ? (0, import_react14.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null,
-+ approval.reason ? (0, import_react14.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null,
-+ approval.detail?.code ? (0, import_react14.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null,
-+ approval.answerable ? (0, import_react14.createElement)(
- "div",
+- "div",
- { className: "bt-secret-edit" },
- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }),
- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }),
@@ -9920,15 +9616,7 @@ diff --git a/client.js b/client.js
- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete"))
- )
- ) : null
-+ { className: "bt-mw-actions" },
-+ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")),
-+ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")),
-+ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react14.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label)))
-+ ) : (0, import_react14.createElement)(
-+ "div",
-+ { className: "bt-mw-actions" },
-+ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name }))
-+ )
++ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null
);
}
-function AddSecret({ actions, run, onDone }) {
@@ -9944,22 +9632,33 @@ diff --git a/client.js b/client.js
- onDone();
- }
- });
-+function MemberWork({ entry, bot, room: room2, actions, now }) {
-+ const [stopping, setStopping] = (0, import_react14.useState)(false);
-+ if (entry.state === "ask") {
+- };
+- return (0, import_react5.createElement)(
++function MemberDock({ sessionId, useRoster, useWork, actions }) {
++ const roster = useRoster((value) => value);
++ const work = useWork((value) => value);
++ const room2 = roster.roomsById[sessionId];
++ const bot = room2 ? void 0 : roster.byId[sessionId];
++ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0;
++ const busy = room2 ? busyMembers(room2, work, roster) : [];
++ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : [];
++ const shown = busy.length > 0 || groups.length > 0;
++ const now = useNow(shown);
++ const ref = (0, import_react14.useRef)(null);
++ useFlowColumn(ref, shown, sessionId);
++ if (!shown) return null;
++ if (room2) {
++ const { rows: rows2, rest } = splitBusy(busy);
++ const open = () => actions.showDetails(sessionId);
+ return (0, import_react14.createElement)(
+ "div",
-+ { className: "bt-mw-member" },
-+ (0, import_react14.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`),
-+ entry.approvals.map((approval) => (0, import_react14.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions }))
++ { ref, className: "bt-mw-dock-stack" },
++ rows2.map((entry) => (0, import_react14.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })),
++ rest.length > 0 ? (0, import_react14.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null
+ );
+ }
-+ if (entry.state !== "work") return null;
-+ const stop = () => {
-+ setStopping(true);
-+ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false));
- };
-- return (0, import_react5.createElement)(
++ const rows = groups.slice(0, ROWS_MAX);
++ const more = groups.length - rows.length;
+ return (0, import_react14.createElement)(
"div",
- { className: "bt-set-row bt-set-paste bt-secret-add" },
@@ -9967,17 +9666,27 @@ diff --git a/client.js b/client.js
- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }),
- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })),
- (0, import_react5.createElement)(
-+ { className: "bt-mw-member" },
-+ (0, import_react14.createElement)(
++ { ref, className: "bt-mw-dock-stack" },
++ rows.map((item) => (0, import_react14.createElement)(WorkRow, {
++ key: item.room.id,
++ bot,
++ tool: item.tool,
++ step: item.step,
++ label: t("in {name}", { name: item.room.name }),
++ at: item.since,
++ now,
++ onOpen: () => actions.openSession(item.room.id)
++ })),
++ more > 0 ? (0, import_react14.createElement)(
"div",
- { className: "bt-secret-row-actions" },
- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")),
- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")),
- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key"))
-+ { className: "bt-mw-state bt-mw-work" },
-+ (0, import_react14.createElement)("span", null, (0, import_react14.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)),
-+ entry.here && entry.doing.handed !== true ? (0, import_react14.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null
- )
+- )
++ { className: "bt-mw-row bt-mw-line bt-mw-static" },
++ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more))
++ ) : null
);
}
-function SecretsPage({ roster, actions, run, Section }) {
@@ -10066,82 +9775,109 @@ diff --git a/client.js b/client.js
-.bt-secret-danger svg{width:13px;height:13px}
-.bt-secret-add .bt-input{height:34px}
-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}}
--`;
--
--// src/client/turn-reasoning.js
--function turnReasoning(steps, current) {
-- const members = steps.length ? steps : [current];
-- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return "";
-- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n");
-+function useMemberWork(room2, useWork) {
-+ const work = useWork?.((value) => value) ?? NO_WORK;
-+ const entries2 = room2 ? memberWork(room2, work) : [];
-+ const now = useNow(entries2.some((entry) => entry.state !== "idle"));
-+ return { entries: entries2, now };
- }
--var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "");
--var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning");
--var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim();
--
--// src/client/cells.js
--var import_react6 = require("react");
--var import_react_dom = require("react-dom");
--var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
--var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") });
--function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) {
-- const onClick = (event) => {
-- const target = mentionTarget(event);
-- if (target === null) return;
-- event.preventDefault();
-- event.stopPropagation();
-- actions.openSession(target);
-- };
-- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null;
-- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick },
-- texts2.map((text, index) => {
-- const key = msgKey ? `${msgKey}:${index}` : void 0;
-- const picked = key ? reactions?.[index] ?? [] : [];
-- const last = index === texts2.length - 1;
-- const live = streaming && last;
-- return (0, import_react6.createElement)(
-- "div",
-- { key: index, className: "bt-msg" },
-- (0, import_react6.createElement)(
-- "div",
-- { className: "bt-msg-line" },
-- lead && last ? lead : null,
-- (0, import_react6.createElement)(
-- "div",
-- { className: "bt-bubble" },
-- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions })
-- ),
-- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions })
-- ),
-- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", {
-- key: emoji,
-- type: "button",
-- className: "bt-react",
-- "aria-label": t("Remove reaction {emoji}", { emoji }),
-- onClick: () => actions.toggleReaction(msgKey, index, emoji)
-- }, emoji))) : null
-- );
-- })
-+var MEMBER_CSS = `
-+.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none}
-+.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out}
-+.bt-mw-row:hover{background:var(--bt-hover)}
-+.bt-mw-static{cursor:default;padding-inline-start:38px}
-+.bt-mw-static:hover{background:none}
-+.bt-mw-row>.bt-mark{flex:none}
-+.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-+.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap}
-+.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px}
-+.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer}
-+.bt-mw-stop:hover{background:var(--bt-hover)}
-+.bt-mw-stop:disabled{opacity:.5;cursor:default}
-+.bt-mw-line{gap:8px}
++function useFlowColumn(ref, active, key) {
++ (0, import_react14.useLayoutEffect)(() => {
++ const node = ref.current;
++ const flow = document.querySelector("[data-chat-flow]");
++ if (!active || !node || !flow) return void 0;
++ const place = () => {
++ node.style.marginInlineStart = "0px";
++ const column = flow.getBoundingClientRect();
++ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`;
++ node.style.width = `${Math.round(column.width)}px`;
++ };
++ place();
++ const observer = new ResizeObserver(place);
++ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target);
++ return () => observer.disconnect();
++ }, [active, key]);
++}
++function useNow(active) {
++ const [now, setNow] = (0, import_react14.useState)(Date.now);
++ (0, import_react14.useEffect)(() => {
++ if (!active) return void 0;
++ const timer = setInterval(() => setNow(Date.now()), 3e4);
++ return () => clearInterval(timer);
++ }, [active]);
++ return now;
++}
++var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : "";
++var alwaysChoices = (approval, bot) => bot?.remote ? [] : [
++ ["always-bot", "Always allow for this Bot"],
++ ...approval.roomId ? [["always-room", "Always allow for this group"]] : []
++];
++function ApprovalCard({ approval, bot, actions }) {
++ const [busy, setBusy] = (0, import_react14.useState)(false);
++ const answer = (choice) => {
++ setBusy(true);
++ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false));
++ };
++ return (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) },
++ (0, import_react14.createElement)("div", { className: "bt-mw-tool" }, approval.tool),
++ approval.detail?.text ? (0, import_react14.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null,
++ approval.reason ? (0, import_react14.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null,
++ approval.detail?.code ? (0, import_react14.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null,
++ approval.answerable ? (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-actions" },
++ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")),
++ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")),
++ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react14.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label)))
++ ) : (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-actions" },
++ (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name }))
++ )
++ );
++}
++function MemberWork({ entry, bot, room: room2, actions, now }) {
++ const [stopping, setStopping] = (0, import_react14.useState)(false);
++ if (entry.state === "ask") {
++ return (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-member" },
++ (0, import_react14.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`),
++ entry.approvals.map((approval) => (0, import_react14.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions }))
++ );
++ }
++ if (entry.state !== "work") return null;
++ const stop = () => {
++ setStopping(true);
++ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false));
++ };
++ return (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-member" },
++ (0, import_react14.createElement)(
++ "div",
++ { className: "bt-mw-state bt-mw-work" },
++ (0, import_react14.createElement)("span", null, (0, import_react14.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)),
++ entry.here && entry.doing.handed !== true ? (0, import_react14.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null
++ )
++ );
++}
++function useMemberWork(room2, useWork) {
++ const work = useWork?.((value) => value) ?? NO_WORK;
++ const entries2 = room2 ? memberWork(room2, work) : [];
++ const now = useNow(entries2.some((entry) => entry.state !== "idle"));
++ return { entries: entries2, now };
++}
++var MEMBER_CSS = `
++.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none}
++.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out}
++.bt-mw-row:hover{background:var(--bt-hover)}
++.bt-mw-static{cursor:default;padding-inline-start:38px}
++.bt-mw-static:hover{background:none}
++.bt-mw-row>.bt-mark{flex:none}
++.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap}
++.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px}
++.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer}
++.bt-mw-stop:hover{background:var(--bt-hover)}
++.bt-mw-stop:disabled{opacity:.5;cursor:default}
++.bt-mw-line{gap:8px}
+.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0}
+.bt-mw-stack>*+*{margin-left:-4px}
+.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%}
@@ -10161,8 +9897,13 @@ diff --git a/client.js b/client.js
+.bt-mw-actions>button:disabled{opacity:.5;cursor:default}
+@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}}
+@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}}
-+`;
-+
+ `;
+
+-// src/client/turn-reasoning.js
+-function turnReasoning(steps, current) {
+- const members = steps.length ? steps : [current];
+- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return "";
+- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n");
+// src/shared/task-board.js
+var COLUMNS = ["not-started", "in-progress", "done"];
+var PRIORITIES = ["urgent", "high", "normal", "low"];
@@ -10214,14 +9955,61 @@ diff --git a/client.js b/client.js
+ const value = task?.progress;
+ if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0;
+ return Math.max(0, Math.min(100, Math.round(value)));
-+}
-+
+ }
+-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "");
+-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning");
+-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim();
+
+-// src/client/cells.js
+-var import_react6 = require("react");
+-var import_react_dom = require("react-dom");
+-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
+-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") });
+-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) {
+- const onClick = (event) => {
+- const target = mentionTarget(event);
+- if (target === null) return;
+- event.preventDefault();
+- event.stopPropagation();
+- actions.openSession(target);
+- };
+- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null;
+- return (0, import_react6.createElement)(
+// src/client/read-only.js
+var readOnlySaid = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== "";
+function readOnlyLine(h50, roster, actions, { key, className, style, reload = true } = {}) {
+ if (!readOnlySaid(roster)) return null;
+ return h50(
-+ "div",
+ "div",
+- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick },
+- texts2.map((text, index) => {
+- const key = msgKey ? `${msgKey}:${index}` : void 0;
+- const picked = key ? reactions?.[index] ?? [] : [];
+- const last = index === texts2.length - 1;
+- const live = streaming && last;
+- return (0, import_react6.createElement)(
+- "div",
+- { key: index, className: "bt-msg" },
+- (0, import_react6.createElement)(
+- "div",
+- { className: "bt-msg-line" },
+- lead && last ? lead : null,
+- (0, import_react6.createElement)(
+- "div",
+- { className: "bt-bubble" },
+- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions })
+- ),
+- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions })
+- ),
+- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", {
+- key: emoji,
+- type: "button",
+- className: "bt-react",
+- "aria-label": t("Remove reaction {emoji}", { emoji }),
+- onClick: () => actions.toggleReaction(msgKey, index, emoji)
+- }, emoji))) : null
+- );
+- })
+ { key, className: ["bt-readonly-line", className].filter(Boolean).join(" "), style },
+ h50("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)),
+ reload ? h50("button", {
@@ -10608,18 +10396,7 @@ diff --git a/client.js b/client.js
+ return Promise.resolve(actions.answerApproval(card.approval.id, choice));
+ }
+ return void 0;
- }
--function MessageTools({ text, name, sessionId, react, actions }) {
-- const [panel, setPanel] = (0, import_react6.useState)(null);
-- const [place, setPlace] = (0, import_react6.useState)(null);
-- const [copied, setCopied] = (0, import_react6.useState)(false);
-- const tools = (0, import_react6.useRef)(null);
-- const pop = (0, import_react6.useRef)(null);
-- const close = () => setPanel(null);
-- useDismiss(panel !== null, close, [tools, pop]);
-- const open = (kind, event) => {
-- if (panel === kind) {
-- close();
++}
+var answerSlots = /* @__PURE__ */ new Map();
+var answerSlot = (id) => {
+ let slot = answerSlots.get(id);
@@ -10689,26 +10466,11 @@ diff --git a/client.js b/client.js
+ const selected = multi ? slot.picked.filter((label) => label !== "") : [];
+ if (selected.length === 0 && custom === "") return;
+ onAnswer(card, { selected, custom });
- return;
- }
-- const rect = event.currentTarget.getBoundingClientRect();
-- const below = rect.bottom + 220 < window.innerHeight;
-- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } });
-- setPanel(kind);
-- };
-- const copy2 = () => {
-- void navigator.clipboard?.writeText(text).then(() => {
-- setCopied(true);
-- setTimeout(() => setCopied(false), 1e3);
-- });
++ return;
++ }
+ if (custom === "") return;
+ onAnswer(card, custom);
- };
-- const download2 = () => {
-- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" }));
-- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` });
-- link.click();
-- setTimeout(() => URL.revokeObjectURL(url), 1e3);
++ };
+ const choose = (label) => {
+ if (busy) return;
+ if (!multi) {
@@ -10717,31 +10479,7 @@ diff --git a/client.js b/client.js
+ }
+ slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label];
+ redraw((count) => count + 1);
- };
-- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => {
-- close();
-- run();
-- } }, (0, import_react6.createElement)(Icon), label);
-- return (0, import_react6.createElement)(
-- "div",
-- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 },
-- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null,
-- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null,
-- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)),
-- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)(
-- "div",
-- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place },
-- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => {
-- close();
-- react(emoji);
-- } }, emoji))
-- ), document.body) : null,
-- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)(
-- "div",
-- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place },
-- item(t("Copy"), CopyIcon, copy2),
-- item(t("Download"), DownloadIcon, download2)
-- ), document.body) : null
++ };
+ return (0, import_react15.createElement)(
+ "span",
+ { className: "bt-inbox-answer" },
@@ -10771,14 +10509,8 @@ diff --git a/client.js b/client.js
+ }
+ }),
+ (0, import_react15.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit"))
- );
- }
--function ThinkingRow({ text }) {
-- return (0, import_react6.createElement)(
-- "details",
-- { className: "bt-think" },
-- (0, import_react6.createElement)("summary", null, t("Thought it through")),
-- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text)
++ );
++}
+function InboxRow({ wide, useRoster, useWork, useUi, actions }) {
+ const roster = useRoster((value) => value);
+ const work = useWork ? useWork((value) => value) : {};
@@ -10801,54 +10533,12 @@ diff --git a/client.js b/client.js
+ (0, import_react15.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react15.createElement)(InboxIcon)),
+ wide ? (0, import_react15.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null,
+ count > 0 ? (0, import_react15.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null
- );
- }
--function useFileMentions({ node, openFile, fileMentions, useTurnData }) {
-- const tail = useTurnData?.("turn-tail");
-- const location2 = node.location;
-- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0;
-- const seq = node.data.finalNode?.seq;
-- return (0, import_react6.useMemo)(() => {
-- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0;
-- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0;
-- }, [fileMentions, openFile, seq, tail, turn]);
++ );
++}
+function Status({ status }) {
+ const { label, tone, Icon } = STATUSES[status];
+ return (0, import_react15.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react15.createElement)(Icon), (0, import_react15.createElement)("span", null, t(label)));
- }
--function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) {
-- const roster = useRoster((value) => value);
-- const data = node.data;
-- const steps = useTurnSteps(String(data.turn));
-- const contexts = useTurnContext(String(data.turn));
-- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData });
-- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn);
-- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text);
-- const running = data.status === "running";
-- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null);
-- if (hidden) return null;
-- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data);
-- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : [];
-- if (texts2.length === 0 && !reasoning && relays.length === 0) return null;
-- const selfId = roster.byId[sessionId]?.id ?? sessionId;
-- return (0, import_react6.createElement)(
-- import_react6.Fragment,
-- null,
-- relays.map((relay) => {
-- const source = relay.source;
-- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
-- return (0, import_react6.createElement)(EventLine, {
-- key: relay.seq,
-- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"),
-- bots: [sender],
-- onOpen: () => {
-- if (sender.id) actions.openExchange(selfId, sender.id);
-- },
-- whole: true
-- });
-- }),
-- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null,
-- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null
++}
+function whenText(card, now) {
+ if (card.status === "running" || card.status === "task") {
+ return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started");
@@ -10913,21 +10603,19 @@ diff --git a/client.js b/client.js
+ ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react15.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))),
+ (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open"))
+ )
- );
++ );
}
--function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) {
-- if (whole && bots.length === 1) {
-- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-event" },
-- (0, import_react6.createElement)(
-- "button",
-- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) },
-- (0, import_react6.createElement)("span", null, prefix),
-- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }),
-- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name)
-- )
-- );
+-function MessageTools({ text, name, sessionId, react, actions }) {
+- const [panel, setPanel] = (0, import_react6.useState)(null);
+- const [place, setPlace] = (0, import_react6.useState)(null);
+- const [copied, setCopied] = (0, import_react6.useState)(false);
+- const tools = (0, import_react6.useRef)(null);
+- const pop = (0, import_react6.useRef)(null);
+- const close = () => setPanel(null);
+- useDismiss(panel !== null, close, [tools, pop]);
+- const open = (kind, event) => {
+- if (panel === kind) {
+- close();
+function useNow2(ms) {
+ const [now, setNow] = (0, import_react15.useState)(Date.now);
+ (0, import_react15.useEffect)(() => {
@@ -10941,26 +10629,9 @@ diff --git a/client.js b/client.js
+ if (card?.kind === "workflow") {
+ actions.openSettings("workflows", null, "inbox");
+ return;
- }
-- const parts = [];
-- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix));
-- bots.forEach((bot, index) => {
-- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner));
-- parts.push((0, import_react6.createElement)(
-- "button",
-- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) },
-- (0, import_react6.createElement)(BotMark, { bot, size: 16 }),
-- bot.name
-- ));
-- });
-- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts);
++ }
+ actions.openSession(card.chat);
- }
--function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) {
-- const lead = (0, import_react6.createElement)(
-- "button",
-- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) },
-- (0, import_react6.createElement)(BotMark, { bot, size: 22 })
++}
+function closedSettings(state) {
+ if (!state.settings && !state.remoteBotSettings) return state;
+ const back = state.settings?.back === "inbox";
@@ -10993,21 +10664,59 @@ diff --git a/client.js b/client.js
+ const pending = inboxAnswer(actions, card, choice);
+ if (pending === void 0) {
+ setBusy("");
-+ return;
-+ }
+ return;
+ }
+- const rect = event.currentTarget.getBoundingClientRect();
+- const below = rect.bottom + 220 < window.innerHeight;
+- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } });
+- setPanel(kind);
+- };
+- const copy2 = () => {
+- void navigator.clipboard?.writeText(text).then(() => {
+- setCopied(true);
+- setTimeout(() => setCopied(false), 1e3);
+- });
+- };
+- const download2 = () => {
+- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" }));
+- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` });
+- link.click();
+- setTimeout(() => URL.revokeObjectURL(url), 1e3);
+ void Promise.resolve(pending).then(() => {
+ setAnswered((value) => ({ ...value, [card.id]: true }));
+ const name = card.bot?.name ?? "";
+ setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered"));
+ }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy(""));
-+ };
+ };
+- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => {
+- close();
+- run();
+- } }, (0, import_react6.createElement)(Icon), label);
+- return (0, import_react6.createElement)(
+ const open = (card) => openCard(actions, card);
+ return (0, import_react15.createElement)(
-+ "div",
+ "div",
+- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 },
+- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null,
+- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null,
+- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)),
+- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)(
+- "div",
+- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place },
+- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => {
+- close();
+- react(emoji);
+- } }, emoji))
+- ), document.body) : null,
+- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)(
+ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 },
+ (0, import_react15.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
+ (0, import_react15.createElement)(
-+ "div",
+ "div",
+- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place },
+- item(t("Copy"), CopyIcon, copy2),
+- item(t("Download"), DownloadIcon, download2)
+- ), document.body) : null
+ { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" },
+ (0, import_react15.createElement)(
+ "div",
@@ -11059,11 +10768,13 @@ diff --git a/client.js b/client.js
+ )
+ )
);
+ }
+-function ThinkingRow({ text }) {
- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-group-msg" },
-- (0, import_react6.createElement)(
-+}
+- "details",
+- { className: "bt-think" },
+- (0, import_react6.createElement)("summary", null, t("Thought it through")),
+- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text)
+var INBOX_CSS = `
+.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px}
+.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
@@ -11181,16 +10892,12 @@ diff --git a/client.js b/client.js
+ onDragEnd: draggable ? () => onDragEnd?.() : void 0
+ },
+ (0, import_react16.createElement)(
- "span",
-- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } },
-- bot.name,
-- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null
++ "span",
+ { className: skin.top },
+ (0, import_react16.createElement)("span", { className: skin.id }, idText),
+ topExtra,
+ badge ? (0, import_react16.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null
- ),
-- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead })
++ ),
+ (0, import_react16.createElement)("span", { className: skin.title }, card?.title || emptyTitle),
+ labels.length ? (0, import_react16.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react16.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null,
+ card?.owner ? (0, import_react16.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null,
@@ -11200,12 +10907,15 @@ diff --git a/client.js b/client.js
+ actions
);
}
--function GroupEvent({ prefix, room: room2, name, roster, actions }) {
-- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-event" },
-- (0, import_react6.createElement)("span", null, prefix),
-- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null
+-function useFileMentions({ node, openFile, fileMentions, useTurnData }) {
+- const tail = useTurnData?.("turn-tail");
+- const location2 = node.location;
+- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0;
+- const seq = node.data.finalNode?.seq;
+- return (0, import_react6.useMemo)(() => {
+- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0;
+- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0;
+- }, [fileMentions, openFile, seq, tail, turn]);
+
+// src/client/kanban.js
+var import_react17 = require("react");
@@ -11217,86 +10927,56 @@ diff --git a/client.js b/client.js
+ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }),
+ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }),
+ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 })
- );
++ );
}
--function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) {
-- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null;
-- const self = roster.byId[sessionId];
-- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim());
-- const other = team || !scope ? void 0 : findBot(scope);
-- const target = other && other.id !== self?.id ? other : self;
-- if (!target) return null;
-- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name });
+-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) {
+- const roster = useRoster((value) => value);
+- const data = node.data;
+- const steps = useTurnSteps(String(data.turn));
+- const contexts = useTurnContext(String(data.turn));
+- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData });
+- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn);
+- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text);
+- const running = data.status === "running";
+- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null);
+- if (hidden) return null;
+- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data);
+- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : [];
+- if (texts2.length === 0 && !reasoning && relays.length === 0) return null;
+- const selfId = roster.byId[sessionId]?.id ?? sessionId;
- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-event" },
-- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label)
-- );
+- import_react6.Fragment,
+- null,
+- relays.map((relay) => {
+- const source = relay.source;
+- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
+- return (0, import_react6.createElement)(EventLine, {
+- key: relay.seq,
+- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"),
+- bots: [sender],
+- onOpen: () => {
+- if (sender.id) actions.openExchange(selfId, sender.id);
+- },
+- whole: true
+- });
+- }),
+- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null,
+- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null
+function shortId(id) {
+ const hex = String(id ?? "").replace(/^task-/, "");
+ return `#${hex.slice(-6)}`;
- }
--function PartLine({ time }) {
-- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null);
++}
+var ARCHIVE_SHOWN = 20;
+function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) {
+ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0));
+ return [...tasks ?? [], ...newest.slice(0, limit)];
- }
--var scrollParent = (node) => {
-- for (let at = node.parentElement; at; at = at.parentElement) {
-- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at;
++}
+function boardColumns(tasks) {
+ const columns = { "not-started": [], "in-progress": [], done: [] };
+ for (const task of tasks ?? []) {
+ const column = columnOf(task);
+ (columns[column] ?? columns["not-started"]).push(task);
- }
-- return null;
--};
--function EarlierParts({ sessionId, roster, actions }) {
-- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null });
-- const top = (0, import_react6.useRef)(null);
-- const busy = (0, import_react6.useRef)(false);
-- const alive = (0, import_react6.useRef)(true);
-- const restore = (0, import_react6.useRef)(null);
-- const load = (cursor) => {
-- if (busy.current) return;
-- busy.current = true;
-- setView((current) => ({ ...current, loading: true, failed: false }));
-- actions.earlier(sessionId, cursor).then((page) => {
-- busy.current = false;
-- if (!alive.current) return;
-- const scroller = top.current && scrollParent(top.current);
-- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop };
-- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt }));
-- }, () => {
-- busy.current = false;
-- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true }));
-- });
-- };
-- (0, import_react6.useEffect)(() => {
-- alive.current = true;
-- load(void 0);
-- return () => {
-- alive.current = false;
-- };
-- }, [sessionId]);
-- (0, import_react6.useLayoutEffect)(() => {
-- const pending = restore.current;
-- restore.current = null;
-- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd;
-- }, [view.items]);
-- (0, import_react6.useEffect)(() => {
-- const node = top.current;
-- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0;
-- const observer = new IntersectionObserver((entries2) => {
-- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor);
-- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" });
-- observer.observe(node);
-- return () => observer.disconnect();
-- }, [view.cursor, view.loading, view.failed]);
-- const selfId = roster.byId[sessionId]?.id;
++ }
+ for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+ return columns;
+}
@@ -11342,11 +11022,7 @@ diff --git a/client.js b/client.js
+ children.set(parent, list);
+ }
+ for (const list of children.values()) list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || String(a.name).localeCompare(String(b.name)));
- const rows = [];
-- for (const item of view.items) {
-- const last = rows.at(-1);
-- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text);
-- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item);
++ const rows = [];
+ const walk = (parent, depth) => {
+ for (const bot of children.get(parent) ?? []) {
+ rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot });
@@ -11393,113 +11069,8 @@ diff --git a/client.js b/client.js
+ return t("Failed");
+ default:
+ return t("Not started");
- }
-- return (0, import_react6.createElement)(
-- "div",
-- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading },
-- (0, import_react6.createElement)(
-- "div",
-- { ref: top, className: "bt-earlier-top" },
-- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null
-- ),
-- rows.map((row, index) => {
-- const key = rows.length - index;
-- switch (row.kind) {
-- case "divider":
-- return (0, import_react6.createElement)(PartLine, { key, time: row.time });
-- case "user":
-- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)(
-- "div",
-- { className: "bt-ububble" },
-- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null,
-- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null
-- ));
-- case "answer":
-- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } });
-- case "bot":
-- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions });
-- case "event": {
-- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions });
-- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" };
-- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from");
-- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => {
-- if (selfId && other.id) actions.openExchange(selfId, other.id);
-- }, whole: true });
-- }
-- default:
-- return null;
-- }
-- }),
-- (0, import_react6.createElement)(PartLine, { time: view.startedAt })
-- );
- }
--function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) {
-- const roster = useRoster((value) => value);
-- const data = node.data;
-- const source = data.source !== null && typeof data.source === "object" ? data.source : {};
-- const selfId = roster.byId[sessionId]?.id ?? sessionId;
-- const deferred = source.kind === "bot" && source.deferred === true;
-- const relayed = isRelayContext(data);
-- const turn = node.location?.turn?.turn ?? data.turn;
-- (0, import_react6.useLayoutEffect)(() => {
-- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId);
-- }, [relayed, sessionId, turn, source.senderSessionId]);
-- const order = useSessions((list) => {
-- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
-- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : "";
-- });
-- const peers = useTurnClock((state) => {
-- if (!relayed) return "";
-- const turns = state[sessionId] ?? {};
-- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(",");
-- });
-- const visit = useUi((state) => state.visits?.[sessionId]);
-- const hiddenTurns = roster.exchangeTurns[sessionId];
-- const unreadOpening = useTurnClock((state) => {
-- const first = openingTurn(state[sessionId], order, hiddenTurns);
-- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : "";
-- });
-- const body = triggerBody();
-- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body;
-- function triggerBody() {
-- if (source.kind === "bot") {
-- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions });
-- if (source.role === "kickoff" || source.role === "room") return null;
-- if (source.role === "secret" || deferred) return null;
-- if (source.role === "post") {
-- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null;
-- const room2 = roster.roomsById[sessionId];
-- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
-- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi });
-- }
-- if (source.role === "report") {
-- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions });
-- }
-- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart"));
-- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text"));
-- const run = relayed ? commRunOf(
-- order.split(",").filter(Boolean).map(Number),
-- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])),
-- turn,
-- hiddenTurns
-- ) : null;
-- if (run === "folded") return null;
-- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
-- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1);
-- const bots = [sender];
-- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) {
-- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer);
-- }
-- return (0, import_react6.createElement)(EventLine, {
-- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"),
-- bots,
-- onOpen: (bot) => actions.openExchange(selfId, bot.id),
-- whole: true
-- });
-- }
-- if (source.kind === "schedule") {
-- const prompts = reminderPrompts(contentText(data.content));
-- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started"));
++ }
++}
+function dropLabel(column) {
+ if (column === "in-progress") return t("Start task");
+ if (column === "done") return t("Complete task");
@@ -11521,11 +11092,9 @@ diff --git a/client.js b/client.js
+ if (!map.has(id)) {
+ map.set(id, []);
+ order.push(id);
- }
-- if (source.kind === "user-question-reply") return null;
-- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update"));
++ }
+ map.get(id).push(task);
- }
++ }
+ return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) }));
+}
+var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task];
@@ -11567,9 +11136,7 @@ diff --git a/client.js b/client.js
+ };
+ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId);
+ return { own, groupTasks, tasks, archive: done, replace };
- }
--function NewDivider() {
-- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New")));
++}
+function RailButton({ row, selected, meta, onSelect }) {
+ return (0, import_react17.createElement)(
+ "button",
@@ -11585,23 +11152,11 @@ diff --git a/client.js b/client.js
+ },
+ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name),
+ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null
-+ );
+ );
}
--function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) {
-- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open;
-- (0, import_react6.useEffect)(() => {
-- if (!open) turnProcess.setOpen(true);
-- }, [open, turnProcess]);
-- const turn = node?.location?.turn?.turn ?? node?.data?.turn;
-- const start = node?.location?.turn?.start?.time;
-- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn));
-- (0, import_react6.useEffect)(() => {
-- actions.noteTurnTime(sessionId, turn, "first", start);
-- }, [sessionId, turn, start]);
-- const visit = useUi((state) => state.visits?.[sessionId]);
-- const fresh = useTurnClock((state) => {
-- const own = state[sessionId]?.[turn];
-- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered;
+-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) {
+- if (whole && bots.length === 1) {
+- return (0, import_react6.createElement)(
+function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) {
+ const column = columnOf(task);
+ const priority = priorityOf(task);
@@ -11655,32 +11210,11 @@ diff --git a/client.js b/client.js
+ dragValue: task.id,
+ onDragStart: () => onDrag?.(task.id),
+ onDragEnd: () => onDrag?.(null)
- });
-- return fresh ? (0, import_react6.createElement)(NewDivider) : null;
--}
--function Nothing() {
-- return null;
- }
--var SEPARATOR_GAP_MS = 30 * 60 * 1e3;
--function clockLabel(time, now = Date.now()) {
-- const date = new Date(time);
-- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
-- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5);
-- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" });
-- if (days === 0) return t("Today {time}", { time: clock4 });
-- if (days === 1) return t("Yesterday {time}", { time: clock4 });
-- const sameYear = date.getFullYear() === new Date(now).getFullYear();
-- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`;
++ });
++}
+function cardProps(task, roster, onMove, onDrag, partOf2, locked) {
+ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 };
- }
--function nextTurnStart(turns, turn) {
-- let next;
-- for (const key in turns) {
-- const candidate = Number(key);
-- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate;
-- }
-- return next === void 0 ? void 0 : turns[next].first;
++}
+function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) {
+ const tree = lanes ? columnTree(tasks) : null;
+ const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : [];
@@ -11714,7 +11248,14 @@ diff --git a/client.js b/client.js
+ dragging ? (0, import_react17.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null,
+ tasks.length === 0 && !dragging ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null,
+ tree ? tree.nests.map((nest) => (0, import_react17.createElement)(
-+ "div",
+ "div",
+- { className: "bt-event" },
+- (0, import_react6.createElement)(
+- "button",
+- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) },
+- (0, import_react6.createElement)("span", null, prefix),
+- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }),
+- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name)
+ { key: nest.task.id, className: "bt-board-nest" },
+ (0, import_react17.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)),
+ nest.children.map((child) => (0, import_react17.createElement)(
@@ -11731,15 +11272,7 @@ diff --git a/client.js b/client.js
+ lane.tasks.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) }))
+ )) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) }))
+ );
- }
--function openingTurn(turns, firstTurns, hiddenTurns) {
-- if (!turns || !firstTurns) return null;
-- const start = nextTurnStart(turns, -Infinity);
-- if (start === void 0) return null;
-- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]);
-- const listed = firstTurns.split(",").map(Number);
-- const earlier = listed.filter((entry) => entry < turn);
-- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null;
++}
+function BoardView({ roster, actions, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) {
+ const columns = boardColumns(tasks);
+ const lanes = scope?.kind === "group";
@@ -11800,33 +11333,11 @@ diff --git a/client.js b/client.js
+ archiveFull,
+ locked
+ }))
-+ )
+ )
+- );
+ )
+ );
- }
--var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered;
--function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) {
-- const turn = location2.turn;
-- const time = location2.end.time;
-- (0, import_react6.useEffect)(() => {
-- actions.noteTurnTime(sessionId, turn, "end", time);
-- }, [sessionId, turn, time]);
-- const label = useTurnClock((state) => {
-- const start = nextTurnStart(state[sessionId] ?? {}, turn);
-- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : "";
-- });
-- const visit = useUi((state) => state.visits?.[sessionId]);
-- const fresh = useTurnClock((state) => {
-- if (typeof visit?.seen !== "number" || time > visit.seen) return false;
-- const start = nextTurnStart(state[sessionId] ?? {}, turn);
-- return start !== void 0 && start > visit.seen && start <= visit.entered;
-- });
-- if (!label && !fresh) return null;
-- return (0, import_react6.createElement)(
-- import_react6.Fragment,
-- null,
-- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null,
-- fresh ? (0, import_react6.createElement)(NewDivider) : null
++}
+function KanbanRow({ wide, useUi, actions }) {
+ const open = useUi ? useUi((value) => Boolean(value.board)) : false;
+ const label = t("Tasks");
@@ -11843,48 +11354,13 @@ diff --git a/client.js b/client.js
+ },
+ (0, import_react17.createElement)("span", { className: "bt-board-mark" }, (0, import_react17.createElement)(BoardIcon)),
+ wide ? (0, import_react17.createElement)("span", { className: "bt-board-label" }, label) : null
- );
- }
--function Shimmer({ children }) {
-- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children);
++ );
++}
+function scopeName(roster, scope) {
+ if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group");
+ if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || "";
+ return "";
- }
--var PHRASES = {
-- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"],
-- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"]
--};
--var TOOL_PHRASES = [
-- [/web_?search|search_?web/i, "Searching online"],
-- [/fetch|browse|browser|web/i, "Reading a page"],
-- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"],
-- [/^(read|view|cat)\b|read_/i, "Opening a file"],
-- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"],
-- [/write|edit|patch|apply|replace/i, "Writing changes"],
-- [/image|photo|draw/i, "Working on an image"]
--];
--var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" };
--function toolActivity(name, arg, findBot, self) {
-- switch (name) {
-- case "ask_user":
-- return null;
-- case "request_secret":
-- return null;
-- case "list_secrets":
-- return { label: t("Checking the keys"), state: "searching" };
-- case "ask_user_question":
-- return { label: t("Waiting for your answer"), state: "alert" };
-- case "group_relay":
-- return { relay: true, state: "orbit" };
-- case "message_bot": {
-- const target = findBot(arg("to"));
-- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" };
-- }
-- case "stop_bot": {
-- const target = findBot(arg("bot"));
-- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" };
++}
+function KanbanDialog({ roster, actions, leaving = false }) {
+ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== "";
+ const rail = boardRail(roster);
@@ -11913,7 +11389,19 @@ diff --git a/client.js b/client.js
+ if (task.status === "cancelled" || task.status === "failed") continue;
+ bucket.total += 1;
+ if (columnOf(task) === "done") bucket.done += 1;
-+ }
+ }
+- const parts = [];
+- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix));
+- bots.forEach((bot, index) => {
+- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner));
+- parts.push((0, import_react6.createElement)(
+- "button",
+- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) },
+- (0, import_react6.createElement)(BotMark, { bot, size: 16 }),
+- bot.name
+- ));
+- });
+- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts);
+ const move = async (id, column) => {
+ if (locked) return;
+ const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id);
@@ -11923,12 +11411,7 @@ diff --git a/client.js b/client.js
+ replace(await actions.taskUpdate({ id, column }));
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
- }
-- case "create_bot":
-- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" };
-- case "update_bot": {
-- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self);
-- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" };
++ }
+ };
+ const create = async (title) => {
+ if (locked) return;
@@ -11940,24 +11423,7 @@ diff --git a/client.js b/client.js
+ setDraft("");
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
- }
-- case "list_bots":
-- return { label: t("Checking the team"), state: "searching" };
-- case "read_group_chat":
-- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" };
-- case "read_own_chat":
-- return { label: t("Looking back through the chat"), state: "searching" };
-- case "remember":
-- return { label: t("Saving to memory"), state: "working" };
-- case "forget":
-- return { label: t("Updating memory"), state: "working" };
-- case "recall":
-- return { label: t("Checking memory"), state: "searching" };
-- default:
-- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" };
-- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1];
-- return { label: phrase ? t(phrase) : null, state: toolState(name) };
-- }
++ }
+ };
+ const name = scopeName(roster, scope);
+ return (0, import_react17.createElement)(
@@ -11997,10 +11463,7 @@ diff --git a/client.js b/client.js
+ })
+ )
+ );
- }
--function workPhrase(tool) {
-- if (!tool) return t("Thinking about it");
-- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it");
++}
+var KANBAN_CSS = `
+.bt-board-row{min-height:40px;padding:6px 8px;gap:10px}
+.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
@@ -12063,14 +11526,7 @@ diff --git a/client.js b/client.js
+ .bt-mem.bt-board{width:100vw;border-radius:0;border:0}
+ .bt-board-columns{flex-direction:column}
+ .bt-board-column{min-width:0}
- }
--function useLiveNote(actions, sessionId, id, entry) {
-- const key = entry ? JSON.stringify(entry) : "";
-- (0, import_react6.useEffect)(() => {
-- if (!key) return void 0;
-- actions.noteLive(sessionId, id, JSON.parse(key));
-- return () => actions.noteLive(sessionId, id, null);
-- }, [sessionId, id, key]);
++}
+`;
+
+// src/client/role-labels.js
@@ -12079,21 +11535,8 @@ diff --git a/client.js b/client.js
+ if (role === null || role === void 0 || role === "") return "";
+ return t(ROLE_LABELS[role] ?? String(role));
}
--function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) {
-- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null });
-- (0, import_react6.useEffect)(() => {
-- if (done) return void 0;
-- let stop = false;
-- let timer;
-- let seen = "";
-- const tick = async () => {
-- const result = await actions.roomProgress(sessionId);
-- if (stop) return;
-- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0];
-- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : "";
-- if (entry && key !== seen) {
-- seen = key;
-- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null });
+-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) {
+- const lead = (0, import_react6.createElement)(
+
+// src/client/projects-nav.js
+var import_react18 = require("react");
@@ -12140,7 +11583,11 @@ diff --git a/client.js b/client.js
+}
+function ProjectList({ projects, error, note, onOpen, onRole }) {
+ const rows = projects === null ? (0, import_react18.createElement)("div", { className: "bt-projects-note" }, t("Reading Projects…")) : error !== "" ? (0, import_react18.createElement)("div", { className: "bt-projects-note" }, error) : projects.length === 0 ? (0, import_react18.createElement)("div", { className: "bt-projects-empty" }, t("No Projects yet")) : projects.map((project) => (0, import_react18.createElement)(
-+ "button",
+ "button",
+- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) },
+- (0, import_react6.createElement)(BotMark, { bot, size: 22 })
+- );
+- return (0, import_react6.createElement)(
+ {
+ key: String(project.id),
+ type: "button",
@@ -12152,114 +11599,140 @@ diff --git a/client.js b/client.js
+ onContextMenu: (event) => {
+ event.preventDefault();
+ onRole?.(project, event);
- }
-- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3);
++ }
+ },
+ (0, import_react18.createElement)("span", { className: "bt-project-entry-name" }, projectName(project)),
+ roleText(project.role) === "" ? null : (0, import_react18.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role))
+ ));
+ return (0, import_react18.createElement)(
-+ "div",
+ "div",
+- { className: "bt-group-msg" },
+- (0, import_react6.createElement)(
+- "span",
+- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } },
+- bot.name,
+- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null
+- ),
+- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead })
+ { className: "bt-projects-list", role: "group", "aria-label": t("Projects") },
+ rows,
+ note === "" || note === void 0 ? null : (0, import_react18.createElement)("div", { className: "bt-projects-note", role: "status" }, note)
-+ );
-+}
+ );
+ }
+-function GroupEvent({ prefix, room: room2, name, roster, actions }) {
+- return (0, import_react6.createElement)(
+- "div",
+- { className: "bt-event" },
+- (0, import_react6.createElement)("span", null, prefix),
+- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null
+function Chevron() {
+ return (0, import_react18.createElement)(
+ "svg",
+ { width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+ (0, import_react18.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })
-+ );
-+}
+ );
+ }
+-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) {
+- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null;
+- const self = roster.byId[sessionId];
+- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim());
+- const other = team || !scope ? void 0 : findBot(scope);
+- const target = other && other.id !== self?.id ? other : self;
+- if (!target) return null;
+- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name });
+- return (0, import_react6.createElement)(
+- "div",
+- { className: "bt-event" },
+- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label)
+function ProjectsMark() {
+ return (0, import_react18.createElement)(
+ "svg",
+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true },
+ (0, import_react18.createElement)("path", { d: "M2.5 5.5h6l1.5 2h7.5v8a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" }),
+ (0, import_react18.createElement)("path", { d: "M2.5 5.5v-1a1 1 0 0 1 1-1h4l1.5 2" })
-+ );
-+}
+ );
+ }
+-function PartLine({ time }) {
+- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null);
+-}
+-var scrollParent = (node) => {
+- for (let at = node.parentElement; at; at = at.parentElement) {
+- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at;
+- }
+- return null;
+-};
+-function EarlierParts({ sessionId, roster, actions }) {
+- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null });
+- const top = (0, import_react6.useRef)(null);
+- const busy = (0, import_react6.useRef)(false);
+- const alive = (0, import_react6.useRef)(true);
+- const restore = (0, import_react6.useRef)(null);
+- const load = (cursor) => {
+- if (busy.current) return;
+- busy.current = true;
+- setView((current) => ({ ...current, loading: true, failed: false }));
+- actions.earlier(sessionId, cursor).then((page) => {
+- busy.current = false;
+- if (!alive.current) return;
+- const scroller = top.current && scrollParent(top.current);
+- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop };
+- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt }));
+- }, () => {
+- busy.current = false;
+- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true }));
+- });
+- };
+- (0, import_react6.useEffect)(() => {
+- alive.current = true;
+- load(void 0);
+function ProjectRoleMenu({ project, x, y, onPick, onClose }) {
+ const ref = (0, import_react18.useRef)(null);
+ (0, import_react18.useEffect)(() => {
+ const onDown = (event) => {
+ if (!ref.current?.contains(event.target)) onClose();
- };
-- void tick();
++ };
+ const onKey = (event) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("mousedown", onDown, true);
+ window.addEventListener("keydown", onKey, true);
return () => {
-- stop = true;
-- clearTimeout(timer);
+- alive.current = false;
+ window.removeEventListener("mousedown", onDown, true);
+ window.removeEventListener("keydown", onKey, true);
};
-- }, [done, sessionId, callId]);
-- let replies = live.replies;
-- if (done) {
-- try {
-- replies = JSON.parse(contentText(content)).replies ?? [];
-- } catch {
-- replies = [];
-- }
+- }, [sessionId]);
+- (0, import_react6.useLayoutEffect)(() => {
+- const pending = restore.current;
+- restore.current = null;
+- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd;
+- }, [view.items]);
+- (0, import_react6.useEffect)(() => {
+- const node = top.current;
+- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0;
+- const observer = new IntersectionObserver((entries2) => {
+- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor);
+- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" });
+- observer.observe(node);
+- return () => observer.disconnect();
+- }, [view.cursor, view.loading, view.failed]);
+- const selfId = roster.byId[sessionId]?.id;
+- const rows = [];
+- for (const item of view.items) {
+- const last = rows.at(-1);
+- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text);
+- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item);
- }
-- const pending = done || !live.speaking ? [] : [live.speaking];
-- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0;
-- if (replies.length === 0 && pending.length === 0) return null;
-- const room2 = roster.roomsById[sessionId];
- return (0, import_react6.createElement)(
+ }, [onClose]);
+ const items = projectMenuItems(project);
+ return (0, import_react_dom3.createPortal)((0, import_react18.createElement)(
"div",
-- { className: "bt-group-replies" },
-- replies.map((reply, index) => {
-- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" };
-- if (reply.kind === "user") {
-- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)(
-- "div",
-- { className: "bt-ububble" },
-- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" })
-- ));
-- }
-- if (reply.kind === "failed") {
-- return (0, import_react6.createElement)(
-- "div",
-- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") },
-- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) })
-- );
-- }
-- if (reply.kind === "lookup") {
-- return (0, import_react6.createElement)(
-- "div",
-- { key: `${reply.botId}-${index}`, className: "bt-event" },
-- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name })
-- );
-- }
-- if (reply.kind === "missed") {
-- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name }));
-- }
-- if (reply.kind === "resumed") {
-- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name }));
-- }
-- if (reply.kind === "cut") {
-- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name }));
-- }
-- if (reply.kind === "approval" || reply.kind === "working") {
-- return (0, import_react6.createElement)(
-- "div",
-- { key: `${reply.botId}-${index}`, className: "bt-event" },
-- (0, import_react6.createElement)(
-- "button",
-- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) },
-- (0, import_react6.createElement)(BotMark, { bot, size: 16 }),
-- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name }))
-- )
-- );
+- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading },
+- (0, import_react6.createElement)(
+- "div",
+- { ref: top, className: "bt-earlier-top" },
+- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null
+ {
+ ref,
+ className: "bt-project-menu",
@@ -12279,9 +11752,7 @@ diff --git a/client.js b/client.js
+ onClick: () => {
+ if (item.pick) onPick(item.role);
+ else onClose();
- }
-- 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 }));
++ }
+ }, item.label))
+ ), document.body);
+}
@@ -12339,13 +11810,36 @@ diff --git a/client.js b/client.js
+ (0, import_react18.createElement)("span", { className: "bt-projects-mark" }, (0, import_react18.createElement)(ProjectsMark)),
+ wide ? (0, import_react18.createElement)("span", { className: "bt-projects-label" }, t("Projects")) : null,
+ wide ? (0, import_react18.createElement)("span", { className: "bt-projects-chevron", "aria-hidden": true }, (0, import_react18.createElement)(Chevron)) : null
-+ ),
-+ listed ? (0, import_react18.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null,
-+ menu === null ? null : (0, import_react18.createElement)(ProjectRoleMenu, {
-+ project: menu.project,
-+ x: menu.x,
-+ y: menu.y,
-+ onClose: () => setMenu(null),
+ ),
+- rows.map((row, index) => {
+- const key = rows.length - index;
+- switch (row.kind) {
+- case "divider":
+- return (0, import_react6.createElement)(PartLine, { key, time: row.time });
+- case "user":
+- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)(
+- "div",
+- { className: "bt-ububble" },
+- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null,
+- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null
+- ));
+- case "answer":
+- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } });
+- case "bot":
+- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions });
+- case "event": {
+- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions });
+- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" };
+- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from");
+- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => {
+- if (selfId && other.id) actions.openExchange(selfId, other.id);
+- }, whole: true });
++ listed ? (0, import_react18.createElement)(ProjectList, { projects, error, note, onOpen: open, onRole: (project, event) => setMenu({ project, x: event.clientX, y: event.clientY }) }) : null,
++ menu === null ? null : (0, import_react18.createElement)(ProjectRoleMenu, {
++ project: menu.project,
++ x: menu.x,
++ y: menu.y,
++ onClose: () => setMenu(null),
+ onPick: async (role) => {
+ const project = menu.project;
+ setMenu(null);
@@ -12355,33 +11849,82 @@ diff --git a/client.js b/client.js
+ if (answer?.changed === true) void load();
+ } catch (failure) {
+ setNote(failure?.message ?? String(failure));
-+ }
+ }
+- default:
+- return null;
}
-- 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 });
- }),
-- pending.map((id) => {
-- const bot = roster.byId[id];
-- if (!bot) return null;
-- return (0, import_react6.createElement)(
-- "div",
-- { key: `pending-${id}`, className: "bt-activity" },
-- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }),
-- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name }))
-- );
- })
+- (0, import_react6.createElement)(PartLine, { time: view.startedAt })
++ })
);
}
--var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]);
--var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]);
--function callTree(root) {
-- const calls = [];
-- const seen = /* @__PURE__ */ new Set();
-- const visit = (call2) => {
-- const id = call2.callId ?? call2;
-- if (seen.has(id)) return;
-- seen.add(id);
-- calls.push(call2);
-- for (const child of call2.subCalls ?? []) visit(child);
+-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) {
+- const roster = useRoster((value) => value);
+- const data = node.data;
+- const source = data.source !== null && typeof data.source === "object" ? data.source : {};
+- const selfId = roster.byId[sessionId]?.id ?? sessionId;
+- const deferred = source.kind === "bot" && source.deferred === true;
+- const relayed = isRelayContext(data);
+- const turn = node.location?.turn?.turn ?? data.turn;
+- (0, import_react6.useLayoutEffect)(() => {
+- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId);
+- }, [relayed, sessionId, turn, source.senderSessionId]);
+- const order = useSessions((list) => {
+- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : "";
+- });
+- const peers = useTurnClock((state) => {
+- if (!relayed) return "";
+- const turns = state[sessionId] ?? {};
+- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(",");
+- });
+- const visit = useUi((state) => state.visits?.[sessionId]);
+- const hiddenTurns = roster.exchangeTurns[sessionId];
+- const unreadOpening = useTurnClock((state) => {
+- const first = openingTurn(state[sessionId], order, hiddenTurns);
+- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : "";
+- });
+- const body = triggerBody();
+- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body;
+- function triggerBody() {
+- if (source.kind === "bot") {
+- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions });
+- if (source.role === "kickoff" || source.role === "room") return null;
+- if (source.role === "secret" || deferred) return null;
+- if (source.role === "post") {
+- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null;
+- const room2 = roster.roomsById[sessionId];
+- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
+- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi });
+- }
+- if (source.role === "report") {
+- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions });
+- }
+- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart"));
+- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text"));
+- const run = relayed ? commRunOf(
+- order.split(",").filter(Boolean).map(Number),
+- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])),
+- turn,
+- hiddenTurns
+- ) : null;
+- if (run === "folded") return null;
+- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" };
+- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1);
+- const bots = [sender];
+- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) {
+- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer);
+- }
+- return (0, import_react6.createElement)(EventLine, {
+- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"),
+- bots,
+- onOpen: (bot) => actions.openExchange(selfId, bot.id),
+- whole: true
+- });
+- }
+- if (source.kind === "schedule") {
+- const prompts = reminderPrompts(contentText(data.content));
+- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started"));
+
+// src/client/sidebar.js
+var import_react19 = require("react");
@@ -12423,9 +11966,7 @@ diff --git a/client.js b/client.js
+ const onContextMenu = (event) => {
+ event.preventDefault();
+ actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY });
- };
-- visit(root);
-- return calls;
++ };
+ const pinned = bot.pinned === true && !tile;
+ const remote = Boolean(bot.remote);
+ const shown = remote ? friendBotTitle(bot) : bot.name;
@@ -12492,7 +12033,10 @@ diff --git a/client.js b/client.js
+ event.stopPropagation();
+ keepSidebarStill(event.currentTarget);
+ actions.openSettings("bots", id);
-+ }
+ }
+- if (source.kind === "user-question-reply") return null;
+- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update"));
+- }
+ }, (0, import_react19.createElement)(GearIcon)) : null;
+ if (settings) return (0, import_react19.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings);
+ return row;
@@ -12506,14 +12050,8 @@ diff --git a/client.js b/client.js
+ reset();
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
}
--function useShownCards(actions, sessionId, callIds) {
-- const key = JSON.stringify(callIds);
-- (0, import_react6.useLayoutEffect)(() => {
-- const ids = JSON.parse(key);
-- if (ids.length === 0 || !actions.noteCards) return void 0;
-- actions.noteCards(sessionId, ids, 1);
-- return () => actions.noteCards(sessionId, ids, -1);
-- }, [sessionId, key]);
+-function NewDivider() {
+- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New")));
+function RemoteMark({ remote }) {
+ return (0, import_react19.createElement)(
+ "span",
@@ -12526,82 +12064,23 @@ diff --git a/client.js b/client.js
+ )
+ );
}
--function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) {
-- const tool = toolName(call2);
-- const done = call2.kind === "tool-result";
-- const { text: arg, value: argValue } = toolArgs(call2);
-- const self = roster.byId[sessionId];
-- switch (tool) {
-- case "message_bot": {
-- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" };
-- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name }));
-- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
-- }
-- case "stop_bot": {
-- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" };
-- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name }));
-- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
-- }
-- case "create_bot": {
-- const created = findBot(arg("name"));
-- if (!done || created === void 0) return null;
-- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) });
-- }
-- case "update_bot": {
-- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self);
-- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null;
-- }
-- case "group_relay":
-- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork });
-- case "create_group":
-- case "update_group":
-- case "delete_group":
-- case "post_to_group": {
-- const verbs = {
-- create_group: ["Created group", "Couldn't create the group"],
-- update_group: ["Updated group", "Couldn't update the group"],
-- delete_group: ["Deleted group", "Couldn't delete the group"],
-- post_to_group: ["Posted in", "Couldn't post in the group"]
-- }[tool];
-- if (!done) return null;
-- const result = contentText(call2.content);
-- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result);
-- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1]));
-- const name = named[2];
-- const room2 = roster.rooms.find((entry) => entry.name === name);
-- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions });
-- }
-- case "ask_user": {
-- const question = roster.questions?.[sessionId];
-- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true });
-- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true });
-- }
-- case "request_secret":
-- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions });
-- case "ask_user_question": {
-- if (!done) return null;
-- let parsed = null;
-- try {
-- parsed = JSON.parse(contentText(call2.content));
-- } catch {
-- parsed = null;
-- }
-- const answer = parsed?.answers?.[0];
-- const question = argValue("questions")?.[0];
-- if (!answer) return null;
-- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer }));
-- }
-- case "read_group_chat": {
-- if (!done) return null;
-- const room2 = roster.rooms.find((entry) => entry.name === arg("group"));
-- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null;
-- }
-- case "remember":
-- case "forget":
-- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null;
-- default:
-- return null;
-- }
+-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) {
+- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open;
+- (0, import_react6.useEffect)(() => {
+- if (!open) turnProcess.setOpen(true);
+- }, [open, turnProcess]);
+- const turn = node?.location?.turn?.turn ?? node?.data?.turn;
+- const start = node?.location?.turn?.start?.time;
+- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn));
+- (0, import_react6.useEffect)(() => {
+- actions.noteTurnTime(sessionId, turn, "first", start);
+- }, [sessionId, turn, start]);
+- const visit = useUi((state) => state.visits?.[sessionId]);
+- const fresh = useTurnClock((state) => {
+- const own = state[sessionId]?.[turn];
+- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered;
+- });
+- return fresh ? (0, import_react6.createElement)(NewDivider) : null;
+function isPastFaceRoom(room2) {
+ if (room2?.origin !== "face-to-face") return false;
+ const status = room2.face?.status;
@@ -12609,21 +12088,8 @@ diff --git a/client.js b/client.js
+ const expires = room2.face?.expiresAt;
+ return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now();
}
--function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) {
-- const roster = useRoster((value) => value);
-- const root = node.data.root;
-- const turn = node.location?.turn?.turn ?? root.turn;
-- const hidden = isHiddenTurn(roster, sessionId, turn);
-- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase());
-- const calls = hidden ? [] : callTree(root);
-- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2)));
-- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result");
-- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null;
-- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note);
-- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId));
-- if (drawn.length === 0) return null;
-- const env = { sessionId, roster, actions, useUi, useWork, findBot };
-- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env))));
+-function Nothing() {
+- return null;
+function FaceMark() {
+ return (0, import_react19.createElement)(
+ "span",
@@ -12637,17 +12103,16 @@ diff --git a/client.js b/client.js
+ )
+ );
}
--
--// src/client/activity-row.js
--var import_react7 = require("react");
--var import_react_dom2 = require("react-dom");
--var PHRASE_HOLD_MS = 800;
--var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]);
--function elapsedLabel(ms) {
-- const minutes = Math.floor(ms / 6e4);
-- if (minutes < 60) return `${Math.max(1, minutes)}m`;
-- const hours = Math.floor(minutes / 60);
-- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`;
+-var SEPARATOR_GAP_MS = 30 * 60 * 1e3;
+-function clockLabel(time, now = Date.now()) {
+- const date = new Date(time);
+- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
+- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5);
+- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" });
+- if (days === 0) return t("Today {time}", { time: clock4 });
+- if (days === 1) return t("Yesterday {time}", { time: clock4 });
+- const sameYear = date.getFullYear() === new Date(now).getFullYear();
+- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`;
+function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
+ const id = room2.id;
+ const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster));
@@ -12696,7 +12161,12 @@ diff --git a/client.js b/client.js
+ ) : null,
+ wide && alert && !active ? (0, import_react19.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react19.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null
+ );
-+}
+ }
+-function nextTurnStart(turns, turn) {
+- let next;
+- for (const key in turns) {
+- const candidate = Number(key);
+- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate;
+function AgentSessionRow({ session, agent, actions, renaming, index }) {
+ const input = (0, import_react19.useRef)(null);
+ (0, import_react19.useLayoutEffect)(() => {
@@ -12735,7 +12205,8 @@ diff --git a/client.js b/client.js
+ onBlur: () => commit(true)
+ })
+ );
-+ }
+ }
+- return next === void 0 ? void 0 : turns[next].first;
+ return (0, import_react19.createElement)(
+ "button",
+ {
@@ -12754,27 +12225,14 @@ diff --git a/client.js b/client.js
+ (0, import_react19.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "")
+ );
}
--function useRunningHost(active) {
-- const [host, setHost] = (0, import_react7.useState)(null);
-- (0, import_react7.useEffect)(() => {
-- if (!active) {
-- setHost(null);
-- return void 0;
-- }
-- let frame = 0;
-- const find = () => {
-- frame = 0;
-- const node = document.querySelector("[data-chat-flow] > [data-chat-running]");
-- setHost((prev) => prev === node ? prev : node);
-- };
-- const observer = new MutationObserver(() => {
-- if (!frame) frame = requestAnimationFrame(find);
-- });
-- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
-- find();
-- return () => {
-- cancelAnimationFrame(frame);
-- observer.disconnect();
+-function openingTurn(turns, firstTurns, hiddenTurns) {
+- if (!turns || !firstTurns) return null;
+- const start = nextTurnStart(turns, -Infinity);
+- if (start === void 0) return null;
+- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]);
+- const listed = firstTurns.split(",").map(Number);
+- const earlier = listed.filter((entry) => entry < turn);
+- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null;
+function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) {
+ const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => {
+ const row = list.byId[id];
@@ -12785,9 +12243,7 @@ diff --git a/client.js b/client.js
+ at: row?.updatedAt ?? 0,
+ running: row?.running === true,
+ active: (row?.retainedBy?.mainView ?? 0) > 0
- };
-- }, [active]);
-- return host;
++ };
+ }).sort((a, b) => b.at - a.at)));
+ const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({
+ id,
@@ -12862,41 +12318,26 @@ diff --git a/client.js b/client.js
+ )
+ );
}
--function useRowEntrances(replacing) {
-- const check = (0, import_react7.useRef)(replacing);
-- check.current = replacing;
-- (0, import_react7.useEffect)(() => {
-- const seen = /* @__PURE__ */ new WeakSet();
-- const started = Date.now();
-- let column = null;
-- let frame = 0;
-- const settle = (event) => {
-- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return;
-- delete event.currentTarget.dataset.btEnter;
-- event.currentTarget.removeEventListener("animationend", settle);
-- };
-- const scan = () => {
-- frame = 0;
-- const next = document.querySelector("[data-chat-flow]");
-- if (next !== column) {
-- observer.disconnect();
-- column = next;
-- if (column) observer.observe(column, { childList: true, subtree: true });
-- }
-- if (!column) return;
-- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")];
-- let last = -1;
-- items.forEach((item, index) => {
-- if (seen.has(item)) last = index;
-- });
-- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0);
-- for (const item of fresh) seen.add(item);
-- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return;
-- for (const item of fresh) {
-- if (items.indexOf(item) < last) continue;
-- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind);
-- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new";
-- item.addEventListener("animationend", settle);
+-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered;
+-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) {
+- const turn = location2.turn;
+- const time = location2.end.time;
+- (0, import_react6.useEffect)(() => {
+- actions.noteTurnTime(sessionId, turn, "end", time);
+- }, [sessionId, turn, time]);
+- const label = useTurnClock((state) => {
+- const start = nextTurnStart(state[sessionId] ?? {}, turn);
+- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : "";
+- });
+- const visit = useUi((state) => state.visits?.[sessionId]);
+- const fresh = useTurnClock((state) => {
+- if (typeof visit?.seen !== "number" || time > visit.seen) return false;
+- const start = nextTurnStart(state[sessionId] ?? {}, turn);
+- return start !== void 0 && start > visit.seen && start <= visit.entered;
+- });
+- if (!label && !fresh) return null;
+- return (0, import_react6.createElement)(
+- import_react6.Fragment,
+var GLIDE = springEasing(1e3, 63);
+function useListGlide(listRef, layout, key) {
+ const tops = (0, import_react19.useRef)({ layout, map: /* @__PURE__ */ new Map() });
@@ -12912,22 +12353,11 @@ diff --git a/client.js b/client.js
+ const before = tops.current.map.get(id);
+ if (!still && before !== void 0 && Math.abs(before - top) > 0.5) {
+ row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE);
- }
-- };
-- const observer = new MutationObserver(() => {
-- if (!frame) frame = requestAnimationFrame(scan);
-- });
-- scan();
-- return () => {
-- cancelAnimationFrame(frame);
-- observer.disconnect();
-- };
-- }, []);
++ }
+ }
+ tops.current = { layout, map: next };
+ }, [layout, key]);
- }
--function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) {
++}
+var NodeActions = class extends import_react19.Component {
+ constructor(props) {
+ super(props);
@@ -12948,7 +12378,9 @@ diff --git a/client.js b/client.js
+ const closed = collapsed[bot.id] === true;
+ return (0, import_react19.createElement)(
+ import_react19.Fragment,
-+ null,
+ null,
+- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null,
+- fresh ? (0, import_react6.createElement)(NewDivider) : null
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } },
@@ -12961,51 +12393,54 @@ diff --git a/client.js b/client.js
+ typeof actions === "function" ? (0, import_react19.createElement)(NodeActions, { component: actions, bot }) : null
+ ),
+ closed ? null : children.map((child) => (0, import_react19.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions }))
-+ );
-+}
+ );
+ }
+-function Shimmer({ children }) {
+- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children);
+-}
+-var PHRASES = {
+- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"],
+- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"]
+-};
+-var TOOL_PHRASES = [
+- [/web_?search|search_?web/i, "Searching online"],
+- [/fetch|browse|browser|web/i, "Reading a page"],
+- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"],
+- [/^(read|view|cat)\b|read_/i, "Opening a file"],
+- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"],
+- [/write|edit|patch|apply|replace/i, "Writing changes"],
+- [/image|photo|draw/i, "Working on an image"]
+-];
+-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" };
+-function toolActivity(name, arg, findBot, self) {
+- switch (name) {
+- case "ask_user":
+- return null;
+- case "request_secret":
+- return null;
+- case "list_secrets":
+- return { label: t("Checking the keys"), state: "searching" };
+- case "ask_user_question":
+- return { label: t("Waiting for your answer"), state: "alert" };
+- case "group_relay":
+- return { relay: true, state: "orbit" };
+- case "message_bot": {
+- const target = findBot(arg("to"));
+- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" };
+- }
+- case "stop_bot": {
+- const target = findBot(arg("bot"));
+- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" };
+- }
+- case "create_bot":
+- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" };
+- case "update_bot": {
+- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self);
+- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" };
+function TeamSidebar(props) {
+ const { wide, useRoster, useUi, useSessions, actions } = props;
+ const listRef = (0, import_react19.useRef)(null);
- const roster = useRoster((value) => value);
-- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
-- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {}));
-- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time)));
-- const bot = roster.byId[sessionId];
-- const room2 = roster.roomsById[sessionId];
-- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]);
-- const streaming = entries2.some((entry) => entry.text);
-- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at);
-- const relaying = tools.some((entry) => entry.relay);
-- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0);
-- const tool = tools.at(-1);
-- const inputRef = (0, import_react7.useRef)(0);
-- const inputAt = Math.max(inputRef.current, pendingInput);
-- inputRef.current = inputAt;
-- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" };
-- const [shown, setShown] = (0, import_react7.useState)(null);
-- const wantKey = want?.key ?? null;
-- const markState = (0, import_react7.useRef)("thinking");
-- if (want) markState.current = want.state;
-- (0, import_react7.useEffect)(() => {
-- if (wantKey === null || shown?.key === wantKey) return void 0;
-- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() });
-- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0;
-- if (wait <= 0) {
-- make();
-- return void 0;
-- }
-- const timer = setTimeout(make, wait);
-- return () => clearTimeout(timer);
-- }, [wantKey, shown]);
-- const [phase, setPhase] = (0, import_react7.useState)(null);
-- const endedAt = (0, import_react7.useRef)(0);
-- const shift = (0, import_react7.useRef)(null);
-- const leaving = (0, import_react7.useRef)(false);
-- leaving.current = phase !== null && !visible;
-- (0, import_react7.useEffect)(() => {
-- if (visible) {
-- setPhase("in");
-- return void 0;
++ const roster = useRoster((value) => value);
+ const markedUnread = useUi((value) => value.unread);
+ props.useRegistry((value) => value);
+ const updated = useSessions((list) => {
@@ -13034,39 +12469,36 @@ diff --git a/client.js b/client.js
+ onClick: () => {
+ void Promise.resolve(actions.reloadTeam?.()).catch((error) => actions.reportError(error, "reload"));
}
-- setPhase((current) => current === "in" ? "out" : current);
-- const timer = setTimeout(() => {
-- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null;
-- setPhase((current) => current === "out" ? null : current);
-- }, 140);
-- return () => clearTimeout(timer);
-- }, [visible]);
-- (0, import_react7.useLayoutEffect)(() => {
-- if (phase !== null) return;
-- endedAt.current = Date.now();
-- setShown(null);
-- const before = shift.current;
-- shift.current = null;
-- const scroller = document.querySelector("[data-conversation-scroll]");
-- if (before === null || !scroller || reducedMotion()) return;
-- const delta = before - scroller.scrollTop;
-- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" });
-- }, [phase]);
-- (0, import_react7.useEffect)(() => {
-- if (!running) actions.clearLive(sessionId);
-- }, [running, sessionId]);
-- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300);
-- const [now, setNow] = (0, import_react7.useState)(Date.now);
-- (0, import_react7.useEffect)(() => {
-- if (phase !== "in") return void 0;
-- const timer = setInterval(() => setNow(Date.now()), 1e4);
-- return () => clearInterval(timer);
-- }, [phase]);
-- const host = useRunningHost(running);
-- if (!host || phase === null || !shown) return null;
-- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null;
-- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)(
-+ }, t("Reload"));
+- case "list_bots":
+- return { label: t("Checking the team"), state: "searching" };
+- case "read_group_chat":
+- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" };
+- case "read_own_chat":
+- return { label: t("Looking back through the chat"), state: "searching" };
+- case "remember":
+- return { label: t("Saving to memory"), state: "working" };
+- case "forget":
+- return { label: t("Updating memory"), state: "working" };
+- case "recall":
+- return { label: t("Checking memory"), state: "searching" };
+- default:
+- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" };
+- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1];
+- return { label: phrase ? t(phrase) : null, state: toolState(name) };
+- }
+-}
+-function workPhrase(tool) {
+- if (!tool) return t("Thinking about it");
+- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it");
+-}
+-function useLiveNote(actions, sessionId, id, entry) {
+- const key = entry ? JSON.stringify(entry) : "";
+- (0, import_react6.useEffect)(() => {
+- if (!key) return void 0;
+- actions.noteLive(sessionId, id, JSON.parse(key));
+- return () => actions.noteLive(sessionId, id, null);
+- }, [sessionId, id, key]);
++ }, t("Reload"));
+ const structure = useSidebarStructure();
+ const [collapsed, setCollapsed] = (0, import_react19.useState)({});
+ const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] }));
@@ -13083,18 +12515,7 @@ diff --git a/client.js b/client.js
+ actions.openPalette(event.key);
+ };
+ return (0, import_react19.createElement)(
- "div",
-- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true },
-- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
-- (0, import_react7.createElement)(
-- "span",
-- { className: "bt-typing-text" },
-- (0, import_react7.createElement)(
-- "span",
-- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" },
-- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`),
-- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null
-- )
++ "div",
+ { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch },
+ wide ? (0, import_react19.createElement)(
+ "div",
@@ -13122,59 +12543,24 @@ diff --git a/client.js b/client.js
+ tree ? (0, import_react19.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react19.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react19.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react19.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")),
+ order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react19.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react19.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react19.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })),
+ wide ? (0, import_react19.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react19.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null
- )
-- ), host);
--}
--
--// src/client/dsh-offer.js
--var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]);
--var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0];
--var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b);
--function showsDshRow(dsh, app) {
-- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false;
-- if (dsh.kind === "npm") return true;
-- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified);
--}
--
--// src/client/sidebar.js
--var import_react8 = require("react");
--var import_react_dom3 = require("react-dom");
--var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
--function useMarkedUnread(id, active, marked, actions) {
-- (0, import_react8.useEffect)(() => {
-- if (active && marked) actions.clearUnread(id);
-- }, [active, marked]);
++ )
+ );
}
--var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon));
--var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false;
--var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : "";
--var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => {
-- const [id, tool] = key.split("|");
-- return { id, tool: tool || void 0 };
--});
--var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`);
--function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) {
-- const id = bot.id;
-- const part = currentPart(bot);
-- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part]));
-- const running = useSessionStatus((map) => map.get(part)?.running === true);
-- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
-- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active;
-- useMarkedUnread(id, active, marked, actions);
-- const main = isMainOf(roster, id);
-- const tile = roster.mainBotId === id;
-- const approval = useApproval(useWork, (entry) => entry.botId === id);
-- const alert = roster.questions?.[part] !== void 0 || approval;
-- const doing = useActivity((map) => map[id]);
-- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`))));
-- const away = !running && groups.length > 0;
-- const state = botState(running || away, doing, alert);
-- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null;
-- if (bot.hidden && !active && !main) return null;
-- const onContextMenu = (event) => {
-- event.preventDefault();
-- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY });
+-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) {
+- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null });
+- (0, import_react6.useEffect)(() => {
+- if (done) return void 0;
+- let stop = false;
+- let timer;
+- let seen = "";
+- const tick = async () => {
+- const result = await actions.roomProgress(sessionId);
+- if (stop) return;
+- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0];
+- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : "";
+- if (entry && key !== seen) {
+- seen = key;
+- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null });
+function UpdateButton({ useUpdate, actions }) {
+ const update = useUpdate((value) => value);
+ const [open, setOpen] = (0, import_react19.useState)(false);
@@ -13193,14 +12579,68 @@ diff --git a/client.js b/client.js
+ event.stopPropagation();
+ setOpen(false);
+ button.current?.focus();
-+ }
-+ };
+ }
+- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3);
+ };
+- void tick();
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
-+ return () => {
+ return () => {
+- stop = true;
+- clearTimeout(timer);
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
-+ };
+ };
+- }, [done, sessionId, callId]);
+- let replies = live.replies;
+- if (done) {
+- try {
+- replies = JSON.parse(contentText(content)).replies ?? [];
+- } catch {
+- replies = [];
+- }
+- }
+- const pending = done || !live.speaking ? [] : [live.speaking];
+- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0;
+- if (replies.length === 0 && pending.length === 0) return null;
+- const room2 = roster.roomsById[sessionId];
+- return (0, import_react6.createElement)(
+- "div",
+- { className: "bt-group-replies" },
+- replies.map((reply, index) => {
+- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" };
+- if (reply.kind === "user") {
+- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)(
+- "div",
+- { className: "bt-ububble" },
+- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" })
+- ));
+- }
+- if (reply.kind === "failed") {
+- return (0, import_react6.createElement)(
+- "div",
+- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") },
+- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) })
+- );
+- }
+- if (reply.kind === "lookup") {
+- return (0, import_react6.createElement)(
+- "div",
+- { key: `${reply.botId}-${index}`, className: "bt-event" },
+- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name })
+- );
+- }
+- if (reply.kind === "missed") {
+- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name }));
+- }
+- if (reply.kind === "resumed") {
+- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name }));
+- }
+- if (reply.kind === "cut") {
+- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name }));
+- }
+- if (reply.kind === "approval" || reply.kind === "working") {
+- return (0, import_react6.createElement)(
+ }, [open]);
+ const phase = update?.phase ?? "idle";
+ const dsh = update?.dsh;
@@ -13212,30 +12652,7 @@ diff --git a/client.js b/client.js
+ const rect = button.current?.getBoundingClientRect();
+ if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 });
+ setOpen((value) => !value);
- };
-- const pinned = bot.pinned === true && !tile;
-- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", ");
-- if (tile && wide) {
-- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)(
-- "button",
-- {
-- type: "button",
-- className: "bt-tile",
-- "aria-label": label,
-- "aria-current": active ? "page" : void 0,
-- "data-bot-id": id,
-- title: preview || bot.name,
-- onClick: () => actions.openSession(id),
-- onContextMenu
-- },
-- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }),
-- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name),
-- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null
-- ));
-- }
-- return (0, import_react8.createElement)(
-- "button",
-- {
++ };
+ const start = () => {
+ setError("");
+ void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure))));
@@ -13253,26 +12670,10 @@ diff --git a/client.js b/client.js
+ null,
+ (0, import_react19.createElement)("button", {
+ ref: button,
- type: "button",
-- className: "bt-row",
++ type: "button",
+ className: "bt-round bt-update",
+ "data-phase": cloudPhase,
- "aria-label": label,
-- "aria-current": active ? "page" : void 0,
-- "data-bot-id": id,
-- title: wide ? void 0 : bot.name,
-- onClick: () => actions.openSession(id),
-- onContextMenu
-- },
-- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }),
-- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null,
-- wide ? (0, import_react8.createElement)(
-- "span",
-- { className: "bt-row-body" },
-- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null),
-- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null
-- ) : null,
-- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null
++ "aria-label": label,
+ title: label,
+ "aria-haspopup": "dialog",
+ "aria-expanded": open,
@@ -13288,7 +12689,25 @@ diff --git a/client.js b/client.js
+ (0, import_react19.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })),
+ message ? (0, import_react19.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null,
+ (0, import_react19.createElement)(
-+ "div",
+ "div",
+- { key: `${reply.botId}-${index}`, className: "bt-event" },
+- (0, import_react6.createElement)(
+- "button",
+- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) },
+- (0, import_react6.createElement)(BotMark, { bot, size: 16 }),
+- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name }))
+- )
+- );
+- }
+- 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_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi });
+- }),
+- pending.map((id) => {
+- const bot = roster.byId[id];
+- if (!bot) return null;
+- return (0, import_react6.createElement)(
+ { className: "bt-update-actions" },
+ update.notesUrl ? (0, import_react19.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null,
+ phase === "installed" ? null : (0, import_react19.createElement)("button", {
@@ -13300,7 +12719,12 @@ diff --git a/client.js b/client.js
+ )
+ ) : null,
+ dshRow ? (0, import_react19.createElement)(
-+ "div",
+ "div",
+- { key: `pending-${id}`, className: "bt-activity" },
+- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }),
+- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name }))
+- );
+- })
+ { className: "bt-update-dsh" },
+ (0, import_react19.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })),
+ (0, import_react19.createElement)("div", { className: "bt-update-text" }, dshText),
@@ -13320,43 +12744,20 @@ diff --git a/client.js b/client.js
+ ), document.body) : null
);
}
--function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
-- const id = room2.id;
-- const fallback = useSessions((list) => previewOf(list, id));
-- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback;
-- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
-- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active;
-- useMarkedUnread(id, active, marked, actions);
-- const approval = useApproval(useWork, (entry) => entry.roomId === id);
-- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`)));
-- const first = roster.byId[members[0]?.id];
-- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null;
-- if (room2.hidden && !active) return null;
-- const pinned = room2.pinned === true;
-- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", ");
-- return (0, import_react8.createElement)(
-- "button",
-- {
-- type: "button",
-- className: "bt-row",
-- "aria-label": label,
-- "aria-current": active ? "page" : void 0,
-- "data-room-id": id,
-- onClick: () => actions.openSession(id),
-- onContextMenu: (event) => {
-- event.preventDefault();
-- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY });
-- }
-- },
-- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }),
-- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null,
-- wide ? (0, import_react8.createElement)(
-- "span",
-- { className: "bt-row-body" },
-- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null),
-- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null
-- ) : null,
-- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null
+-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]);
+-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]);
+-function callTree(root) {
+- const calls = [];
+- const seen = /* @__PURE__ */ new Set();
+- const visit = (call2) => {
+- const id = call2.callId ?? call2;
+- if (seen.has(id)) return;
+- seen.add(id);
+- calls.push(call2);
+- for (const child of call2.subCalls ?? []) visit(child);
+- };
+- visit(root);
+- return calls;
+function ConnectApps({ wide, actions }) {
+ if (!wide) return null;
+ return (0, import_react19.createElement)(
@@ -13369,28 +12770,92 @@ diff --git a/client.js b/client.js
+ t("Connect apps")
+ )
+ );
-+}
+ }
+-function useShownCards(actions, sessionId, callIds) {
+- const key = JSON.stringify(callIds);
+- (0, import_react6.useLayoutEffect)(() => {
+- const ids = JSON.parse(key);
+- if (ids.length === 0 || !actions.noteCards) return void 0;
+- actions.noteCards(sessionId, ids, 1);
+- return () => actions.noteCards(sessionId, ids, -1);
+- }, [sessionId, key]);
+function YouAvatar() {
+ return (0, import_react19.createElement)(
+ "span",
+ { className: "bt-you-wrap" },
+ (0, import_react19.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react19.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })),
+ (0, import_react19.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings"))
- );
++ );
}
--function AgentSessionRow({ session, agent, actions, renaming, index }) {
-- const input = (0, import_react8.useRef)(null);
-- (0, import_react8.useLayoutEffect)(() => {
-- if (!renaming) return;
-- input.current?.focus();
-- input.current?.select();
-- }, [renaming]);
-- if (renaming) {
-- const commit = (keep) => {
-- const value = input.current?.value ?? "";
-- if (!keep || value.trim() === "" || value.trim() === session.title) {
-- actions.endAgentRename();
-- return;
+-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) {
+- const tool = toolName(call2);
+- const done = call2.kind === "tool-result";
+- const { text: arg, value: argValue } = toolArgs(call2);
+- const self = roster.byId[sessionId];
+- switch (tool) {
+- case "message_bot": {
+- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" };
+- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name }));
+- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
+- }
+- case "stop_bot": {
+- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" };
+- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name }));
+- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true });
+- }
+- case "create_bot": {
+- const created = findBot(arg("name"));
+- if (!done || created === void 0) return null;
+- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) });
+- }
+- case "update_bot": {
+- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self);
+- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null;
+- }
+- case "group_relay":
+- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork });
+- case "create_group":
+- case "update_group":
+- case "delete_group":
+- case "post_to_group": {
+- const verbs = {
+- create_group: ["Created group", "Couldn't create the group"],
+- update_group: ["Updated group", "Couldn't update the group"],
+- delete_group: ["Deleted group", "Couldn't delete the group"],
+- post_to_group: ["Posted in", "Couldn't post in the group"]
+- }[tool];
+- if (!done) return null;
+- const result = contentText(call2.content);
+- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result);
+- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1]));
+- const name = named[2];
+- const room2 = roster.rooms.find((entry) => entry.name === name);
+- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions });
+- }
+- case "ask_user": {
+- const question = roster.questions?.[sessionId];
+- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true });
+- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true });
+- }
+- case "request_secret":
+- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions });
+- case "ask_user_question": {
+- if (!done) return null;
+- let parsed = null;
+- try {
+- parsed = JSON.parse(contentText(call2.content));
+- } catch {
+- parsed = null;
+- }
+- const answer = parsed?.answers?.[0];
+- const question = argValue("questions")?.[0];
+- if (!answer) return null;
+- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer }));
+- }
+- case "read_group_chat": {
+- if (!done) return null;
+- const room2 = roster.rooms.find((entry) => entry.name === arg("group"));
+- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null;
+var openShellSettings = null;
+var openShellOnboarding = null;
+var shellShortcuts = null;
@@ -13405,18 +12870,50 @@ diff --git a/client.js b/client.js
+ if (openShellSettings) {
+ openShellSettings();
+ return;
-+ }
+ }
+- case "remember":
+- case "forget":
+- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null;
+- default:
+- return null;
+- }
+ const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]');
+ if (desktop) openThroughMenu(desktop);
+ else pressSettingsShortcut();
+ });
-+}
+ }
+-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) {
+- const roster = useRoster((value) => value);
+- const root = node.data.root;
+- const turn = node.location?.turn?.turn ?? root.turn;
+- const hidden = isHiddenTurn(roster, sessionId, turn);
+- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase());
+- const calls = hidden ? [] : callTree(root);
+- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2)));
+- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result");
+- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null;
+- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note);
+- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId));
+- if (drawn.length === 0) return null;
+- const env = { sessionId, roster, actions, useUi, useWork, findBot };
+- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env))));
+function openHarnessOnboarding(id) {
+ requestAnimationFrame(() => {
+ if (openShellOnboarding) openShellOnboarding(id);
+ else openHarnessSettings();
+ });
-+}
+ }
+-
+-// src/client/activity-row.js
+-var import_react7 = require("react");
+-var import_react_dom2 = require("react-dom");
+-var PHRASE_HOLD_MS = 800;
+-var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]);
+-function elapsedLabel(ms) {
+- const minutes = Math.floor(ms / 6e4);
+- if (minutes < 60) return `${Math.max(1, minutes)}m`;
+- const hours = Math.floor(minutes / 60);
+- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`;
+var MODELS_STEP_ID = "flybot-models";
+var MODELS_STEP_ORDER = 1e3;
+function ModelsSettingsStep({ explicit, complete, openSection }) {
@@ -13425,7 +12922,19 @@ diff --git a/client.js b/client.js
+ complete();
+ }, []);
+ return null;
-+}
+ }
+-function useRunningHost(active) {
+- const [host, setHost] = (0, import_react7.useState)(null);
+- (0, import_react7.useEffect)(() => {
+- if (!active) {
+- setHost(null);
+- return void 0;
+- }
+- let frame = 0;
+- const find = () => {
+- frame = 0;
+- const node = document.querySelector("[data-chat-flow] > [data-chat-running]");
+- setHost((prev) => prev === node ? prev : node);
+function openHarnessModels() {
+ openHarnessOnboarding(MODELS_STEP_ID);
+}
@@ -13474,43 +12983,85 @@ diff --git a/client.js b/client.js
+ openShellSettings = openSettings;
+ return () => {
+ if (openShellSettings === openSettings) openShellSettings = null;
-+ };
+ };
+- const observer = new MutationObserver(() => {
+- if (!frame) frame = requestAnimationFrame(find);
+- });
+- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
+- find();
+ }, [openSettings]);
+ (0, import_react19.useEffect)(() => {
+ if (!openOnboarding) return void 0;
+ openShellOnboarding = openOnboarding;
-+ return () => {
+ return () => {
+- cancelAnimationFrame(frame);
+- observer.disconnect();
+ if (openShellOnboarding === openOnboarding) openShellOnboarding = null;
-+ };
+ };
+- }, [active]);
+- return host;
+-}
+-function useRowEntrances(replacing) {
+- const check = (0, import_react7.useRef)(replacing);
+- check.current = replacing;
+- (0, import_react7.useEffect)(() => {
+- const seen = /* @__PURE__ */ new WeakSet();
+- const started = Date.now();
+- let column = null;
+- let frame = 0;
+- const settle = (event) => {
+- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return;
+- delete event.currentTarget.dataset.btEnter;
+- event.currentTarget.removeEventListener("animationend", settle);
+ }, [openOnboarding]);
+ (0, import_react19.useEffect)(() => {
+ if (!open) return void 0;
+ const onDown = (event) => {
+ if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return;
+ setOpen(false);
-+ };
+ };
+- const scan = () => {
+- frame = 0;
+- const next = document.querySelector("[data-chat-flow]");
+- if (next !== column) {
+- observer.disconnect();
+- column = next;
+- if (column) observer.observe(column, { childList: true, subtree: true });
+- }
+- if (!column) return;
+- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")];
+- let last = -1;
+- items.forEach((item, index) => {
+- if (seen.has(item)) last = index;
+- });
+- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0);
+- for (const item of fresh) seen.add(item);
+- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return;
+- for (const item of fresh) {
+- if (items.indexOf(item) < last) continue;
+- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind);
+- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new";
+- item.addEventListener("animationend", settle);
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setOpen(false);
+ button.current?.focus();
}
-- void (async () => {
-- try {
-- await actions.renameAgentSession(agent.id, session.id, value);
-- actions.endAgentRename();
-- } catch (error) {
-- actions.reportError(error, "agent-rename");
-- }
-- })();
};
-- return (0, import_react8.createElement)(
+- const observer = new MutationObserver(() => {
+- if (!frame) frame = requestAnimationFrame(scan);
+- });
+- scan();
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
-+ return () => {
+ return () => {
+- cancelAnimationFrame(frame);
+- observer.disconnect();
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
-+ };
+ };
+- }, []);
+ }, [open]);
+ const toggle = () => {
+ const rect = button.current?.getBoundingClientRect();
@@ -13538,22 +13089,7 @@ diff --git a/client.js b/client.js
+ (0, import_react19.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react19.createElement)(BotMark, { look: MAIN_LOOK, size: 24 }))
+ ),
+ open && box ? (0, import_react_dom4.createPortal)((0, import_react19.createElement)(
- "div",
-- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } },
-- (0, import_react8.createElement)("input", {
-- ref: input,
-- defaultValue: session.title,
-- "aria-label": t("Rename"),
-- onKeyDown: (event) => {
-- if (event.nativeEvent.isComposing) return;
-- if (event.key === "Enter") commit(true);
-- else if (event.key === "Escape") commit(false);
-- },
-- onBlur: () => commit(true)
-- })
-- );
-- }
-- return (0, import_react8.createElement)(
++ "div",
+ { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box },
+ (0, import_react19.createElement)(FlyBotAccountHeader, { actions }),
+ groups.map((group, index) => (0, import_react19.createElement)(
@@ -13572,7 +13108,34 @@ diff --git a/client.js b/client.js
+ ))
+ ), document.body) : null
+ );
-+}
+ }
+-function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) {
+- const roster = useRoster((value) => value);
+- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
+- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {}));
+- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time)));
+- const bot = roster.byId[sessionId];
+- const room2 = roster.roomsById[sessionId];
+- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]);
+- const streaming = entries2.some((entry) => entry.text);
+- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at);
+- const relaying = tools.some((entry) => entry.relay);
+- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0);
+- const tool = tools.at(-1);
+- const inputRef = (0, import_react7.useRef)(0);
+- const inputAt = Math.max(inputRef.current, pendingInput);
+- inputRef.current = inputAt;
+- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" };
+- const [shown, setShown] = (0, import_react7.useState)(null);
+- const wantKey = want?.key ?? null;
+- const markState = (0, import_react7.useRef)("thinking");
+- if (want) markState.current = want.state;
+- (0, import_react7.useEffect)(() => {
+- if (wantKey === null || shown?.key === wantKey) return void 0;
+- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() });
+- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0;
+- if (wait <= 0) {
+- make();
+function useLandOnMainBot(roster, useSessions, actions) {
+ const mainView = useSessions((list) => {
+ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0);
@@ -13586,22 +13149,148 @@ diff --git a/client.js b/client.js
+ if (bot !== void 0) {
+ lastBot.current = { botId: bot.id, sessionId: id };
+ if (currentPart(bot) !== id) actions.landOn(bot.id);
-+ return void 0;
-+ }
+ return void 0;
+ }
+- const timer = setTimeout(make, wait);
+- return () => clearTimeout(timer);
+- }, [wantKey, shown]);
+- const [phase, setPhase] = (0, import_react7.useState)(null);
+- const endedAt = (0, import_react7.useRef)(0);
+- const shift = (0, import_react7.useRef)(null);
+- const leaving = (0, import_react7.useRef)(false);
+- leaving.current = phase !== null && !visible;
+- (0, import_react7.useEffect)(() => {
+- if (visible) {
+- setPhase("in");
+ if (id !== "" && (roster.roomsById[id] || (actions.isAgentSession?.(id) ?? roster.agentOf[id] !== void 0) || blank3 !== "1")) {
+ lastBot.current = null;
-+ return void 0;
-+ }
+ return void 0;
+ }
+- setPhase((current) => current === "in" ? "out" : current);
+- const timer = setTimeout(() => {
+- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null;
+- setPhase((current) => current === "out" ? null : current);
+- }, 140);
+ const last = lastBot.current;
+ const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400);
-+ return () => clearTimeout(timer);
+ return () => clearTimeout(timer);
+- }, [visible]);
+- (0, import_react7.useLayoutEffect)(() => {
+- if (phase !== null) return;
+- endedAt.current = Date.now();
+- setShown(null);
+- const before = shift.current;
+- shift.current = null;
+- const scroller = document.querySelector("[data-conversation-scroll]");
+- if (before === null || !scroller || reducedMotion()) return;
+- const delta = before - scroller.scrollTop;
+- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" });
+- }, [phase]);
+- (0, import_react7.useEffect)(() => {
+- if (!running) actions.clearLive(sessionId);
+- }, [running, sessionId]);
+- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300);
+- const [now, setNow] = (0, import_react7.useState)(Date.now);
+- (0, import_react7.useEffect)(() => {
+- if (phase !== "in") return void 0;
+- const timer = setInterval(() => setNow(Date.now()), 1e4);
+- return () => clearInterval(timer);
+- }, [phase]);
+- const host = useRunningHost(running);
+- if (!host || phase === null || !shown) return null;
+- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null;
+- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)(
+- "div",
+- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true },
+- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
+- (0, import_react7.createElement)(
+- "span",
+- { className: "bt-typing-text" },
+- (0, import_react7.createElement)(
+- "span",
+- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" },
+- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`),
+- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null
+- )
+- )
+- ), host);
+-}
+-
+-// src/client/dsh-offer.js
+-var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]);
+-var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0];
+-var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b);
+-function showsDshRow(dsh, app) {
+- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false;
+- if (dsh.kind === "npm") return true;
+- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified);
+ }, [mainView, roster]);
-+}
+ }
+-
+-// src/client/sidebar.js
+-var import_react8 = require("react");
+-var import_react_dom3 = require("react-dom");
+-var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives");
+-function useMarkedUnread(id, active, marked, actions) {
+- (0, import_react8.useEffect)(() => {
+- if (active && marked) actions.clearUnread(id);
+- }, [active, marked]);
+function FooterAccount(props) {
+ const seated = props.useUi((value) => value.accountSeated === true);
+ if (seated) return null;
+ return (0, import_react19.createElement)("span", { className: "bt-foot-account" }, (0, import_react19.createElement)(AccountLauncher, { ...props, seat: false }));
-+}
+ }
+-var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon));
+-var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false;
+-var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : "";
+-var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => {
+- const [id, tool] = key.split("|");
+- return { id, tool: tool || void 0 };
+-});
+-var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`);
+-function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) {
+- const id = bot.id;
+- const part = currentPart(bot);
+- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part]));
+- const running = useSessionStatus((map) => map.get(part)?.running === true);
+- const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
+- const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active;
+- useMarkedUnread(id, active, marked, actions);
+- const main = isMainOf(roster, id);
+- const tile = roster.mainBotId === id;
+- const approval = useApproval(useWork, (entry) => entry.botId === id);
+- const alert = roster.questions?.[part] !== void 0 || approval;
+- const doing = useActivity((map) => map[id]);
+- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`))));
+- const away = !running && groups.length > 0;
+- const state = botState(running || away, doing, alert);
+- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null;
+- if (bot.hidden && !active && !main) return null;
+- const onContextMenu = (event) => {
+- event.preventDefault();
+- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY });
+- };
+- const pinned = bot.pinned === true && !tile;
+- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", ");
+- if (tile && wide) {
+- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)(
+- "button",
+- {
+- type: "button",
+- className: "bt-tile",
+- "aria-label": label,
+- "aria-current": active ? "page" : void 0,
+- "data-bot-id": id,
+- title: preview || bot.name,
+- onClick: () => actions.openSession(id),
+- onContextMenu
+- },
+- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }),
+- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name),
+- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null
+- ));
+- }
+- return (0, import_react8.createElement)(
+function BotReturn({ wide, useSurface, actions }) {
+ const mode = useSurface((value) => value.mode);
+ if (mode !== "agent") return null;
@@ -13609,33 +13298,66 @@ diff --git a/client.js b/client.js
"button",
- {
- type: "button",
-- className: "bt-agent-sub-row",
-- "aria-current": session.active ? "page" : void 0,
-- style: { "--i": index },
-- onClick: () => actions.openAgentSession(session.id),
-- onContextMenu: (event) => {
-- event.preventDefault();
-- event.stopPropagation();
-- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY });
-- }
+- className: "bt-row",
+- "aria-label": label,
+- "aria-current": active ? "page" : void 0,
+- "data-bot-id": id,
+- title: wide ? void 0 : bot.name,
+- onClick: () => actions.openSession(id),
+- onContextMenu
- },
-- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title),
-- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "")
+- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }),
+- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null,
+- wide ? (0, import_react8.createElement)(
+- "span",
+- { className: "bt-row-body" },
+- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null),
+- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null
+- ) : null,
+- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null
+ { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") },
+ (0, import_react19.createElement)(FlyBotMark, { size: 20 }),
+ wide ? (0, import_react19.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null
);
}
--function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) {
-- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => {
-- const row = list.byId[id];
-- return {
-- id,
-- // A blank Session's title is the workspace folder name; show the label instead.
-- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "",
-- at: row?.updatedAt ?? 0,
-- running: row?.running === true,
-- active: (row?.retainedBy?.mainView ?? 0) > 0
+-function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
+- const id = room2.id;
+- const fallback = useSessions((list) => previewOf(list, id));
+- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback;
+- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
+- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active;
+- useMarkedUnread(id, active, marked, actions);
+- const approval = useApproval(useWork, (entry) => entry.roomId === id);
+- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`)));
+- const first = roster.byId[members[0]?.id];
+- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null;
+- if (room2.hidden && !active) return null;
+- const pinned = room2.pinned === true;
+- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", ");
+- return (0, import_react8.createElement)(
+- "button",
+- {
+- type: "button",
+- className: "bt-row",
+- "aria-label": label,
+- "aria-current": active ? "page" : void 0,
+- "data-room-id": id,
+- onClick: () => actions.openSession(id),
+- onContextMenu: (event) => {
+- event.preventDefault();
+- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY });
+- }
+- },
+- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }),
+- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null,
+- wide ? (0, import_react8.createElement)(
+- "span",
+- { className: "bt-row-body" },
+- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null),
+- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null
+- ) : null,
+- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null
+- );
+var RETURN_CSS = `
+.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease}
+.bt-return:hover{background:rgba(127,127,127,.14)}
@@ -13730,10 +13452,76 @@ diff --git a/client.js b/client.js
+// src/client/busy-ack-logic.js
+function busyAckText(hostAck) {
+ return typeof hostAck?.text === "string" ? hostAck.text.trim() : "";
-+}
+ }
+-function AgentSessionRow({ session, agent, actions, renaming, index }) {
+- const input = (0, import_react8.useRef)(null);
+- (0, import_react8.useLayoutEffect)(() => {
+- if (!renaming) return;
+- input.current?.focus();
+- input.current?.select();
+- }, [renaming]);
+- if (renaming) {
+- const commit = (keep) => {
+- const value = input.current?.value ?? "";
+- if (!keep || value.trim() === "" || value.trim() === session.title) {
+- actions.endAgentRename();
+- return;
+- }
+- void (async () => {
+- try {
+- await actions.renameAgentSession(agent.id, session.id, value);
+- actions.endAgentRename();
+- } catch (error) {
+- actions.reportError(error, "agent-rename");
+- }
+- })();
+- };
+- return (0, import_react8.createElement)(
+- "div",
+- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } },
+- (0, import_react8.createElement)("input", {
+- ref: input,
+- defaultValue: session.title,
+- "aria-label": t("Rename"),
+- onKeyDown: (event) => {
+- if (event.nativeEvent.isComposing) return;
+- if (event.key === "Enter") commit(true);
+- else if (event.key === "Escape") commit(false);
+- },
+- onBlur: () => commit(true)
+- })
+- );
+- }
+- return (0, import_react8.createElement)(
+- "button",
+- {
+- type: "button",
+- className: "bt-agent-sub-row",
+- "aria-current": session.active ? "page" : void 0,
+- style: { "--i": index },
+- onClick: () => actions.openAgentSession(session.id),
+- onContextMenu: (event) => {
+- event.preventDefault();
+- event.stopPropagation();
+- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY });
+- }
+- },
+- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title),
+- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "")
+- );
+function busyAckPending(hostAck) {
+ return busyAckText(hostAck) === "";
-+}
+ }
+-function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) {
+- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => {
+- const row = list.byId[id];
+- return {
+- id,
+- // A blank Session's title is the workspace folder name; show the label instead.
+- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "",
+- at: row?.updatedAt ?? 0,
+- running: row?.running === true,
+- active: (row?.retainedBy?.mainView ?? 0) > 0
+function pendingBusyPlacement(submissions) {
+ if (!Array.isArray(submissions)) return false;
+ return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering");
@@ -13768,15 +13556,6 @@ diff --git a/client.js b/client.js
+ frame = 0;
+ const node = document.querySelector(RUNNING_HOST);
+ setHost((prev) => prev === node ? prev : node);
-+ };
-+ const observer = new MutationObserver(() => {
-+ if (!frame) frame = requestAnimationFrame(find);
-+ });
-+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
-+ find();
-+ return () => {
-+ cancelAnimationFrame(frame);
-+ observer.disconnect();
};
- }).sort((a, b) => b.at - a.at)));
- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({
@@ -13800,6 +13579,15 @@ diff --git a/client.js b/client.js
- const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", ");
- const open = active && wide;
- return (0, import_react8.createElement)(
++ const observer = new MutationObserver(() => {
++ if (!frame) frame = requestAnimationFrame(find);
++ });
++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true });
++ find();
++ return () => {
++ cancelAnimationFrame(frame);
++ observer.disconnect();
++ };
+ }, [active]);
+ return host;
+}
@@ -14712,7 +14500,35 @@ diff --git a/client.js b/client.js
+ const addOwner = (id) => {
+ const clean = text(id);
+ if (clean !== "" && !owner.includes(clean)) owner.push(clean);
-+ };
+ };
+-}
+-function openHarnessSettings() {
+- requestAnimationFrame(() => {
+- if (openShellSettings) {
+- openShellSettings();
+- return;
+- }
+- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]');
+- if (desktop) openThroughMenu(desktop);
+- else pressSettingsShortcut();
+- });
+-}
+-function openHarnessOnboarding(id) {
+- requestAnimationFrame(() => {
+- if (openShellOnboarding) openShellOnboarding(id);
+- else openHarnessSettings();
+- });
+-}
+-function openThroughMenu(trigger) {
+- const before = new Set(document.querySelectorAll('[role="menu"]'));
+- trigger.click();
+- let frames = 0;
+- const look = () => {
+- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node));
+- const item = menu?.querySelector('button[role="menuitem"]');
+- if (item) item.click();
+- else if (++frames < 30) requestAnimationFrame(look);
+- else pressSettingsShortcut();
+ addOwner(project?.ownerUserId);
+ for (const id of idList(project?.ownerUserIds)) addOwner(id);
+ const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id));
@@ -14727,12 +14543,60 @@ diff --git a/client.js b/client.js
+ role: text(project?.role),
+ choices: idList(project?.choices)
};
+- requestAnimationFrame(look);
+-}
+-function pressSettingsShortcut() {
+- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding;
+- if (!binding) return;
+- const held = new Set(binding.modifiers);
+- window.dispatchEvent(new KeyboardEvent("keydown", {
+- code: binding.code,
+- key: binding.code === "Comma" ? "," : "",
+- bubbles: true,
+- cancelable: true,
+- ctrlKey: held.has("control"),
+- altKey: held.has("alt"),
+- shiftKey: held.has("shift"),
+- metaKey: held.has("meta")
+- }));
}
--function openHarnessSettings() {
-- requestAnimationFrame(() => {
-- if (openShellSettings) {
-- openShellSettings();
-- return;
+-function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) {
+- const [open, setOpen] = (0, import_react8.useState)(false);
+- (0, import_react8.useLayoutEffect)(() => {
+- if (!seat) return void 0;
+- actions.seatAccount(true);
+- return () => actions.seatAccount(false);
+- }, [seat]);
+- const [box, setBox] = (0, import_react8.useState)(null);
+- const button = (0, import_react8.useRef)(null);
+- (0, import_react8.useEffect)(() => {
+- if (settingsOpen) setOpen(false);
+- }, [settingsOpen]);
+- (0, import_react8.useEffect)(() => {
+- if (!openSettings) return void 0;
+- openShellSettings = openSettings;
+- return () => {
+- if (openShellSettings === openSettings) openShellSettings = null;
+- };
+- }, [openSettings]);
+- (0, import_react8.useEffect)(() => {
+- if (!openOnboarding) return void 0;
+- openShellOnboarding = openOnboarding;
+- return () => {
+- if (openShellOnboarding === openOnboarding) openShellOnboarding = null;
+- };
+- }, [openOnboarding]);
+- (0, import_react8.useEffect)(() => {
+- if (!open) return void 0;
+- const onDown = (event) => {
+- if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return;
+- setOpen(false);
+- };
+- const onKey = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- setOpen(false);
+- button.current?.focus();
+var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react21.createElement)(
+ "div",
+ {
@@ -14752,11 +14616,7 @@ diff --git a/client.js b/client.js
+ onOpenBot?.(botId);
+ }
+ }
- }
-- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]');
-- if (desktop) openThroughMenu(desktop);
-- else pressSettingsShortcut();
-- });
++ }
+ },
+ (0, import_react21.createElement)("span", { key: "label", className: "bt-project-team-label" }, label),
+ botId === "" ? (0, import_react21.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react21.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`),
@@ -14777,7 +14637,19 @@ diff --git a/client.js b/client.js
+ if (event.key === "Enter" || event.key === " ") {
+ event?.stopPropagation();
+ onOpenDossier?.({ role, label, botId });
-+ }
+ }
+- };
+- window.addEventListener("mousedown", onDown);
+- window.addEventListener("keydown", onKey, true);
+- return () => {
+- window.removeEventListener("mousedown", onDown);
+- window.removeEventListener("keydown", onKey, true);
+- };
+- }, [open]);
+- const toggle = () => {
+- const rect = button.current?.getBoundingClientRect();
+- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 });
+- setOpen((value) => !value);
+ }
+ }, t("Dossier"))
+);
@@ -14820,12 +14692,7 @@ diff --git a/client.js b/client.js
+ )
+ )
+ );
- }
--function openHarnessOnboarding(id) {
-- requestAnimationFrame(() => {
-- if (openShellOnboarding) openShellOnboarding(id);
-- else openHarnessSettings();
-- });
++}
+function dossierRowOf(team, botId) {
+ const id = text(botId);
+ if (id === "") return null;
@@ -14875,17 +14742,7 @@ diff --git a/client.js b/client.js
+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
+ );
- }
--function openThroughMenu(trigger) {
-- const before = new Set(document.querySelectorAll('[role="menu"]'));
-- trigger.click();
-- let frames = 0;
-- const look = () => {
-- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node));
-- const item = menu?.querySelector('button[role="menuitem"]');
-- if (item) item.click();
-- else if (++frames < 30) requestAnimationFrame(look);
-- else pressSettingsShortcut();
++}
+function projectAgentFor(bot, selfId) {
+ if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null;
+ const projectId = text(bot.projectId);
@@ -14911,7 +14768,24 @@ diff --git a/client.js b/client.js
+ }
+ if (target !== "") onOpen?.(target);
};
-- requestAnimationFrame(look);
+- const groups = [
+- [
+- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")],
+- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()],
+- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")],
+- [t("Bot settings"), GearIcon, () => actions.openSettings("general")]
+- ],
+- [
+- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")]
+- ]
+- ];
+- return (0, import_react8.createElement)(
+- import_react8.Fragment,
+- null,
+- (0, import_react8.createElement)(
+- "button",
+- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle },
+- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 }))
+ if (id === "") return null;
+ return (0, import_react21.createElement)("button", {
+ type: "button",
@@ -14938,12 +14812,20 @@ diff --git a/client.js b/client.js
+ (0, import_react21.createElement)("span", null, [t("In flight"), ": ", (0, import_react21.createElement)("b", { key: "f" }, countText(view.counts.inflight))]),
+ (0, import_react21.createElement)("span", null, [t("Blocked"), ": ", (0, import_react21.createElement)("b", { key: "b" }, countText(view.counts.blocked))]),
+ (0, import_react21.createElement)("span", null, [t("Done"), ": ", (0, import_react21.createElement)("b", { key: "d" }, countText(view.counts.done))])
-+ ),
+ ),
+- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)(
+ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses
+ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a
+ // control that silently disappeared.
+ (0, import_react21.createElement)(
-+ "div",
+ "div",
+- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box },
+- groups.map((group, index) => (0, import_react8.createElement)(
+- import_react8.Fragment,
+- { key: index },
+- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
+- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", {
+- key: label,
+ { className: "bt-project-actions" },
+ (0, import_react21.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")),
+ (0, import_react21.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members"))
@@ -14955,21 +14837,7 @@ diff --git a/client.js b/client.js
+ (0, import_react21.createElement)(ProjectDoneGroup, { view, onOpen }),
+ (0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 })
+ );
- }
--function pressSettingsShortcut() {
-- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding;
-- if (!binding) return;
-- const held = new Set(binding.modifiers);
-- window.dispatchEvent(new KeyboardEvent("keydown", {
-- code: binding.code,
-- key: binding.code === "Comma" ? "," : "",
-- bubbles: true,
-- cancelable: true,
-- ctrlKey: held.has("control"),
-- altKey: held.has("alt"),
-- shiftKey: held.has("shift"),
-- metaKey: held.has("meta")
-- }));
++}
+function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) {
+ const save = request ?? call;
+ const [kind, setKind] = (0, import_react21.useState)("bot");
@@ -15050,16 +14918,20 @@ diff --git a/client.js b/client.js
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react21.createElement)("button", {
-+ type: "button",
+ type: "button",
+- role: "menuitem",
+ className: "bt-soft bt-soft-sm",
+ "data-action": "project-memory-save",
+ disabled: noteSaveDisabled,
+ // A disabled button draws no click, but the guard is what a keyboard or a stale tree
+ // could still reach: neither state sends a request or writes anything.
-+ onClick: () => {
+ onClick: () => {
+- setOpen(false);
+- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
+ if (noteSaveDisabled) return;
+ run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved."));
-+ }
+ }
+- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null))
+ }, t("Save memory"))
+ )
+ ),
@@ -15070,81 +14942,13 @@ diff --git a/client.js b/client.js
+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
+ );
- }
--function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) {
-- const [open, setOpen] = (0, import_react8.useState)(false);
-- (0, import_react8.useLayoutEffect)(() => {
-- if (!seat) return void 0;
-- actions.seatAccount(true);
-- return () => actions.seatAccount(false);
-- }, [seat]);
-- const [box, setBox] = (0, import_react8.useState)(null);
-- const button = (0, import_react8.useRef)(null);
-- (0, import_react8.useEffect)(() => {
-- if (settingsOpen) setOpen(false);
-- }, [settingsOpen]);
-- (0, import_react8.useEffect)(() => {
-- if (!openSettings) return void 0;
-- openShellSettings = openSettings;
-- return () => {
-- if (openShellSettings === openSettings) openShellSettings = null;
-- };
-- }, [openSettings]);
-- (0, import_react8.useEffect)(() => {
-- if (!openOnboarding) return void 0;
-- openShellOnboarding = openOnboarding;
-- return () => {
-- if (openShellOnboarding === openOnboarding) openShellOnboarding = null;
-- };
-- }, [openOnboarding]);
-- (0, import_react8.useEffect)(() => {
-- if (!open) return void 0;
-- const onDown = (event) => {
-- if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return;
-- setOpen(false);
-- };
-- const onKey = (event) => {
-- if (event.key === "Escape") {
-- event.stopPropagation();
-- setOpen(false);
-- button.current?.focus();
-- }
-- };
-- window.addEventListener("mousedown", onDown);
-- window.addEventListener("keydown", onKey, true);
-- return () => {
-- window.removeEventListener("mousedown", onDown);
-- window.removeEventListener("keydown", onKey, true);
-- };
-- }, [open]);
-- const toggle = () => {
-- const rect = button.current?.getBoundingClientRect();
-- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 });
-- setOpen((value) => !value);
++}
+function ProjectMembersPanel({ project, rows, onClose } = {}) {
+ const owners = [];
+ const add = (id) => {
+ const clean = text(id);
+ if (clean !== "" && !owners.includes(clean)) owners.push(clean);
- };
-- const groups = [
-- [
-- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")],
-- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()],
-- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")],
-- [t("Bot settings"), GearIcon, () => actions.openSettings("general")]
-- ],
-- [
-- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")]
-- ]
-- ];
-- return (0, import_react8.createElement)(
-- import_react8.Fragment,
-- null,
-- (0, import_react8.createElement)(
-- "button",
-- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle },
-- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 }))
++ };
+ add(project?.ownerUserId);
+ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id);
+ const boardMembers = [];
@@ -15177,29 +14981,13 @@ diff --git a/client.js b/client.js
+ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
+ (0, import_react21.createElement)("span", null, `@${name}`),
+ (0, import_react21.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
-+ ))
- ),
-- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)(
+ ))
+- ), document.body) : null
++ ),
+ owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
+ (0, import_react21.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
+ (0, import_react21.createElement)(
- "div",
-- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box },
-- groups.map((group, index) => (0, import_react8.createElement)(
-- import_react8.Fragment,
-- { key: index },
-- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
-- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", {
-- key: label,
-- type: "button",
-- role: "menuitem",
-- onClick: () => {
-- setOpen(false);
-- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
-- }
-- }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null))
-- ))
-- ), document.body) : null
++ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
@@ -15353,82 +15141,12 @@ diff --git a/client.js b/client.js
- const mode = useSurface((value) => value.mode);
- if (mode !== "agent") return null;
- return (0, import_react8.createElement)(
-- "button",
-- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") },
-- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }),
-- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null
-+ };
-+ if (state === null) return null;
-+ return (0, import_react21.createElement)(
-+ "div",
-+ { className: "bt-project-overlay" },
-+ (0, import_react21.createElement)("div", { className: "bt-scrim", onClick: close }),
-+ (0, import_react21.createElement)(
-+ "div",
-+ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
-+ (0, import_react21.createElement)(
-+ "div",
-+ { className: "bt-mem-head" },
-+ (0, import_react21.createElement)("h2", {}, t("Project board")),
-+ (0, import_react21.createElement)("span", { className: "bt-mem-head-gap" }),
-+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
-+ ),
-+ (0, import_react21.createElement)(
-+ "div",
-+ { className: "bt-project-body" },
-+ state.project === null ? (0, import_react21.createElement)(
-+ "div",
-+ { className: "bt-project-empty" },
-+ (0, import_react21.createElement)("p", null, t("No Project yet — create one.")),
-+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
-+ close();
-+ actions?.openSettings?.("projects");
-+ } }, t("Open Projects in Settings"))
-+ ) : panel === "dossier" ? (0, import_react21.createElement)(ProjectDossierPanel, {
-+ row: dossierRowOf(state.team, dossier?.botId),
-+ label: dossier?.label ?? "",
-+ onClose: () => setPanel(null),
-+ onOpenBot: (id) => {
-+ close();
-+ actions?.openSession?.(id);
-+ }
-+ }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, {
-+ project: state.project,
-+ rows: state.rows,
-+ done: state.done,
-+ canManage: state.canManage,
-+ tasks: state.tasks,
-+ decisions: state.decisions,
-+ meetings: state.meetings,
-+ queues: state.queues,
-+ metrics: state.metrics,
-+ team: state.team,
-+ // #2041: a decision row opens through the same channel read the page polls with.
-+ call: read,
-+ onSettings: () => setPanel("settings"),
-+ onMembers: () => setPanel("members"),
-+ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
-+ onOpenDossier: (value) => {
-+ void openDossier(value);
-+ },
-+ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
-+ // so it closes first — otherwise the chat opens behind it.
-+ onOpenBot: (id) => {
-+ close();
-+ actions?.openSession?.(id);
-+ },
-+ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
-+ // lands on that Bot or group. Nothing here re-decides which board that is.
-+ onOpen: (board) => {
-+ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
-+ actions?.openBoard?.();
-+ setState(null);
-+ }
-+ })
-+ )
-+ )
- );
- }
+- "button",
+- { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") },
+- (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }),
+- wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null
+- );
+-}
-var RETURN_CSS = `
-.bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease}
-.bt-return:hover{background:rgba(127,127,127,.14)}
@@ -15505,6 +15223,78 @@ diff --git a/client.js b/client.js
-@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}}
-`;
++ };
++ if (state === null) return null;
++ return (0, import_react21.createElement)(
++ "div",
++ { className: "bt-project-overlay" },
++ (0, import_react21.createElement)("div", { className: "bt-scrim", onClick: close }),
++ (0, import_react21.createElement)(
++ "div",
++ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
++ (0, import_react21.createElement)(
++ "div",
++ { className: "bt-mem-head" },
++ (0, import_react21.createElement)("h2", {}, t("Project board")),
++ (0, import_react21.createElement)("span", { className: "bt-mem-head-gap" }),
++ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
++ ),
++ (0, import_react21.createElement)(
++ "div",
++ { className: "bt-project-body" },
++ state.project === null ? (0, import_react21.createElement)(
++ "div",
++ { className: "bt-project-empty" },
++ (0, import_react21.createElement)("p", null, t("No Project yet — create one.")),
++ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
++ close();
++ actions?.openSettings?.("projects");
++ } }, t("Open Projects in Settings"))
++ ) : panel === "dossier" ? (0, import_react21.createElement)(ProjectDossierPanel, {
++ row: dossierRowOf(state.team, dossier?.botId),
++ label: dossier?.label ?? "",
++ onClose: () => setPanel(null),
++ onOpenBot: (id) => {
++ close();
++ actions?.openSession?.(id);
++ }
++ }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, {
++ project: state.project,
++ rows: state.rows,
++ done: state.done,
++ canManage: state.canManage,
++ tasks: state.tasks,
++ decisions: state.decisions,
++ meetings: state.meetings,
++ queues: state.queues,
++ metrics: state.metrics,
++ team: state.team,
++ // #2041: a decision row opens through the same channel read the page polls with.
++ call: read,
++ onSettings: () => setPanel("settings"),
++ onMembers: () => setPanel("members"),
++ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
++ onOpenDossier: (value) => {
++ void openDossier(value);
++ },
++ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
++ // so it closes first — otherwise the chat opens behind it.
++ onOpenBot: (id) => {
++ close();
++ actions?.openSession?.(id);
++ },
++ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
++ // lands on that Bot or group. Nothing here re-decides which board that is.
++ onOpen: (board) => {
++ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
++ actions?.openBoard?.();
++ setState(null);
++ }
++ })
++ )
++ )
++ );
++}
// src/client/header.js
-var import_react9 = require("react");
@@ -16243,51 +16033,10 @@ diff --git a/client.js b/client.js
}
var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause });
async function openCapture() {
-@@ -6073,281 +6073,57 @@ async function openCapture() {
- return Math.min(1, Math.sqrt(sum / frame.length) * 4);
- },
- cancel: close,
-- async finish(maxSeconds) {
-- if (closed || recorder.state === "inactive") {
-- close();
-- return null;
-- }
-- try {
-- await new Promise((resolve) => {
-- recorder.onstop = resolve;
-- recorder.stop();
-- });
-- stream.getTracks().forEach((track) => track.stop());
-- const blob = new Blob(chunks, { type: recorder.mimeType });
-- if (blob.size === 0) return null;
-- const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
-- const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
-- const offline = new OfflineAudioContext(1, length, 16e3);
-- const source = offline.createBufferSource();
-- source.buffer = decoded;
-- source.connect(offline.destination);
-- source.start();
-- return waveBytes((await offline.startRendering()).getChannelData(0));
-- } finally {
-- close();
-- }
-- }
-- };
--}
--async function transcribeWave(speech, catalog, wave, signal) {
-- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
-- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
-- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
-- return String(result.value?.text ?? "").trim();
--}
--function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
-- let spokeAt = 0;
-- let quietSince = 0;
-- return (level, now) => {
-- if (level >= loud) {
-- spokeAt ||= now;
-- quietSince = 0;
-- return "speech";
+@@ -6114,240 +6114,16 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
+ spokeAt ||= now;
+ quietSince = 0;
+ return "speech";
- }
- if (!spokeAt) return "waiting";
- if (level < quiet) quietSince ||= now;
@@ -16428,7 +16177,11 @@ diff --git a/client.js b/client.js
- if (!row || leaving) return;
- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
- close();
-- };
++ }
++ if (!spokeAt) return "waiting";
++ if (level < quiet) quietSince ||= now;
++ return quietSince && now - quietSince >= quietMs ? "done" : "speech";
+ };
- let index = -1;
- return (0, import_react12.createElement)(
- "div",
@@ -16517,52 +16270,6 @@ diff --git a/client.js b/client.js
- )
- )
- );
-+ async finish(maxSeconds) {
-+ if (closed || recorder.state === "inactive") {
-+ close();
-+ return null;
-+ }
-+ try {
-+ await new Promise((resolve) => {
-+ recorder.onstop = resolve;
-+ recorder.stop();
-+ });
-+ stream.getTracks().forEach((track) => track.stop());
-+ const blob = new Blob(chunks, { type: recorder.mimeType });
-+ if (blob.size === 0) return null;
-+ const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
-+ const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
-+ const offline = new OfflineAudioContext(1, length, 16e3);
-+ const source = offline.createBufferSource();
-+ source.buffer = decoded;
-+ source.connect(offline.destination);
-+ source.start();
-+ return waveBytes((await offline.startRendering()).getChannelData(0));
-+ } finally {
-+ close();
-+ }
-+ }
-+ };
-+}
-+async function transcribeWave(speech, catalog, wave, signal) {
-+ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
-+ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
-+ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
-+ return String(result.value?.text ?? "").trim();
-+}
-+function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
-+ let spokeAt = 0;
-+ let quietSince = 0;
-+ return (level, now) => {
-+ if (level >= loud) {
-+ spokeAt ||= now;
-+ quietSince = 0;
-+ return "speech";
-+ }
-+ if (!spokeAt) return "waiting";
-+ if (level < quiet) quietSince ||= now;
-+ return quietSince && now - quietSince >= quietMs ? "done" : "speech";
-+ };
}
// src/client/model-menu.js
@@ -17064,50 +16771,186 @@ diff --git a/client.js b/client.js
className: "bt-folder-input",
autoFocus: true,
value: draft,
-@@ -6759,1401 +6759,1910 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
+@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
}
}),
- (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
-+ );
-+ const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
-+ const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
+ );
+ const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
+ const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
+- const browseView = listing ? (0, import_react14.createElement)(
+ const browseView = listing ? (0, import_react26.createElement)(
-+ "div",
-+ { key: `browse:${listing.path}`, className: "bt-folder-view" },
+ "div",
+ { key: `browse:${listing.path}`, className: "bt-folder-view" },
+- (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react14.createElement)(ChevronLeftIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+- (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
+- parent ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
+- inside.map((entry) => (0, import_react14.createElement)(
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react26.createElement)(ChevronLeftIcon), (0, import_react26.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+ (0, import_react26.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
+ parent ? (0, import_react26.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react26.createElement)(FolderIcon), (0, import_react26.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
+ inside.map((entry) => (0, import_react26.createElement)(
-+ "button",
-+ { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
+ "button",
+ { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
+- (0, import_react14.createElement)(FolderIcon),
+- (0, import_react14.createElement)("span", { className: "bt-menu-label" }, entry.name),
+- (0, import_react14.createElement)(ChevronRightIcon)
+ (0, import_react26.createElement)(FolderIcon),
+ (0, import_react26.createElement)("span", { className: "bt-menu-label" }, entry.name),
+ (0, import_react26.createElement)(ChevronRightIcon)
-+ )),
+ )),
+- inside.length === 0 ? (0, import_react14.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
+- listing.truncated ? (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
+- (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+- (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
+ inside.length === 0 ? (0, import_react26.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
+ listing.truncated ? (0, import_react26.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
+ (0, import_react26.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
-+ ) : null;
-+ const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
+ ) : null;
+ const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
+- return (0, import_react14.createElement)(
+- import_react14.Fragment,
+ return (0, import_react26.createElement)(
+ import_react26.Fragment,
-+ null,
+ null,
+- (0, import_react14.createElement)(
+ (0, import_react26.createElement)(
-+ "button",
-+ {
-+ ref: button,
-+ type: "button",
-+ className: "bt-model-trigger",
-+ "data-wide": wide || void 0,
-+ "aria-haspopup": "menu",
-+ "aria-expanded": isOpen,
-+ "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
-+ title: value,
-+ onClick: toggle
-+ },
+ "button",
+ {
+ ref: button,
+@@ -6798,1362 +6798,1871 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
+ title: value,
+ onClick: toggle
+ },
+- (0, import_react14.createElement)(
+- "span",
+- { className: "bt-model-copy" },
+- (0, import_react14.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
+- value ? (0, import_react14.createElement)("span", { className: "bt-model-provider" }, value) : null
+- ),
+- (0, import_react14.createElement)(Caret)
+- ),
+- shownPlace ? (0, import_react_dom7.createPortal)((0, import_react14.createElement)(
+- "div",
+- { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
+- shownView,
+- problem ? (0, import_react14.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
+- ), document.body) : null
+- );
+-}
+-var FOLDER_CSS = `
+-.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
+-.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
+-.bt-folder-copy .bt-menu-label{flex:none}
+-.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
+-.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
+-.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
+-.bt-folder-input:focus{border-color:var(--bt-accent)}
+-.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
+-.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
+-.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
+-@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
+-`;
+-
+-// src/client/schedules.js
+-var import_react15 = require("react");
+-var POLL_MS = 3e4;
+-var shared = null;
+-var listeners = /* @__PURE__ */ new Set();
+-function publish(view) {
+- shared = view;
+- for (const listener of listeners) listener(view);
+-}
+-function useSchedules(actions) {
+- const [view, setView] = (0, import_react15.useState)(shared);
+- (0, import_react15.useEffect)(() => {
+- listeners.add(setView);
+- let stopped = false;
+- let timer;
+- const load = () => actions.schedules?.().then((next) => {
+- if (!stopped) publish(next);
+- }).catch(() => {
+- }).finally(() => {
+- if (!stopped) timer = setTimeout(load, POLL_MS);
+- });
+- load();
+- return () => {
+- stopped = true;
+- clearTimeout(timer);
+- listeners.delete(setView);
+- };
+- }, []);
+- return view;
+-}
+-var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
+-var clock2 = (time) => String(time ?? "").slice(0, 5);
+-var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
+-function dayAndTime(ms) {
+- const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
+- return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
+-}
+-function weekdayNames(days) {
+- const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
+- return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
+-}
+-function everyText(seconds) {
+- if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
+- if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
+- return t("Every {count} minutes", { count: Math.round(seconds / 60) });
+-}
+-function timingText(task) {
+- const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
+- switch (task.kind) {
+- case "after":
+- case "at":
+- return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
+- case "every":
+- return everyText(task.everySeconds);
+- case "daily":
+- return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
+- case "weekly": {
+- const days = (task.weekdays ?? []).join();
+- const text = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
+- return `${text}${zone}`;
+- }
+- case "cron":
+- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
+- default:
+- return "";
+- }
+-}
+-var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
+-function TaskRow({ task, actions, onView, showBot, roster }) {
+- const [confirming, setConfirming] = (0, import_react15.useState)(false);
+- const [busy, setBusy] = (0, import_react15.useState)(false);
+- (0, import_react15.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 3e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const remove = () => {
+- if (!confirming) {
+- setConfirming(true);
+- return;
+- }
+- setBusy(true);
+- void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
+- };
+- const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
+- return (0, import_react15.createElement)(
+- "div",
+- { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 },
+- showBot ? (0, import_react15.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
+- (0, import_react15.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react15.createElement)("span", { className: "bt-task-title" }, task.title),
+- (0, import_react15.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
+- task.prompt && task.prompt !== task.title ? (0, import_react15.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null
+ (0, import_react26.createElement)(
+ "span",
+ { className: "bt-model-copy" },
@@ -17115,15 +16958,30 @@ diff --git a/client.js b/client.js
+ value ? (0, import_react26.createElement)("span", { className: "bt-model-provider" }, value) : null
+ ),
+ (0, import_react26.createElement)(Caret)
-+ ),
+ ),
+- (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)),
+- (0, import_react15.createElement)("button", {
+- type: "button",
+- className: "bt-mini bt-task-remove",
+- "data-confirm": confirming || void 0,
+- disabled: busy,
+- "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
+- title: confirming ? void 0 : t("Delete task"),
+- onClick: remove
+- }, confirming ? t("Delete") : (0, import_react15.createElement)(TrashIcon))
+ shownPlace ? (0, import_react_dom9.createPortal)((0, import_react26.createElement)(
+ "div",
+ { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
+ shownView,
+ problem ? (0, import_react26.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
+ ), document.body) : null
-+ );
-+}
+ );
+ }
+-var pad = (number) => String(number).padStart(2, "0");
+-function inAnHour() {
+- const date = new Date(Date.now() + 36e5);
+- date.setMinutes(0, 0, 0);
+- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
+var FOLDER_CSS = `
+.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
+.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
@@ -17168,15 +17026,42 @@ diff --git a/client.js b/client.js
+ onChange: (event) => onChange(event.target.value)
+ })
+ );
-+}
+ }
+-var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
+-var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]];
+-function TaskForm({ roster, actions, start, onDone, onCancel }) {
+- const [target, setTarget] = (0, import_react15.useState)(start ?? roster.bots[0]?.id ?? "");
+- const [title, setTitle] = (0, import_react15.useState)("");
+- const [prompt, setPrompt] = (0, import_react15.useState)("");
+- const [kind, setKind] = (0, import_react15.useState)("daily");
+- const [at, setAt] = (0, import_react15.useState)(inAnHour);
+- const [time, setTime] = (0, import_react15.useState)("09:00");
+- const [weekdays, setWeekdays] = (0, import_react15.useState)([1, 2, 3, 4, 5]);
+- const [count, setCount] = (0, import_react15.useState)(1);
+- const [unit, setUnit] = (0, import_react15.useState)("hours");
+- const [busy, setBusy] = (0, import_react15.useState)(false);
+- const [error, setError] = (0, import_react15.useState)("");
+- const timing = () => {
+- if (kind === "at") return { kind, at: new Date(at).toISOString() };
+- if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
+- return { kind, time, timeZone: localZone(), ...kind === "weekly" ? { weekdays } : {} };
+- };
+- const submit = (event) => {
+function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
+ const [error, setError] = (0, import_react27.useState)("");
+ const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
+ const submit = async (event) => {
-+ event.preventDefault();
-+ setError("");
+ event.preventDefault();
+- if (busy) return;
+- setBusy(true);
+ setError("");
+- let when3;
+ let payload;
-+ try {
+ try {
+- when3 = timing();
+- } catch {
+- setError(t("Pick a date and time"));
+- setBusy(false);
+ payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description };
+ if (draft.kind === "mcp" && draft.transport === "stdio") {
+ payload.transport = "stdio";
@@ -17191,34 +17076,59 @@ diff --git a/client.js b/client.js
+ else payload.items = draft.items;
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
-+ return;
-+ }
+ return;
+ }
+- void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => {
+- setError(hostText(failure?.message ?? String(failure)));
+- setBusy(false);
+- });
+ try {
+ await actions.saveCapability(payload);
+ onClose();
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
+ }
-+ };
+ };
+- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
+- return (0, import_react15.createElement)(
+ const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
+ return (0, import_react27.createElement)(
-+ "form",
+ "form",
+- { className: "bt-set-card bt-task-form", onSubmit: submit },
+- (0, import_react15.createElement)(
+ { className: "bt-connector-form", onSubmit: submit },
+ (0, import_react27.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
+ (0, import_react27.createElement)(
-+ "label",
+ "label",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
+- (0, import_react15.createElement)(
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
+ (0, import_react27.createElement)(
-+ "select",
+ "select",
+- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
+- (0, import_react15.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react15.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
+- roster.rooms.length > 0 ? (0, import_react15.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react15.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
+ { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
+ ["mcp", "skill", "pack"].map((kind) => (0, import_react27.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
-+ )
-+ ),
+ )
+ ),
+- (0, import_react15.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+- (0, import_react15.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
+- ),
+- (0, import_react15.createElement)(
+ (0, import_react27.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
+ draft.kind === "mcp" ? (0, import_react27.createElement)(
-+ "label",
-+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ "label",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
+- (0, import_react15.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
+- ),
+- (0, import_react15.createElement)(
+ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
+ (0, import_react27.createElement)(
+ "select",
@@ -17228,7 +17138,11 @@ diff --git a/client.js b/client.js
+ )
+ ) : null,
+ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react27.createElement)(
-+ "div",
+ "div",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("When"))),
+- (0, import_react15.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react15.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
+- (0, import_react15.createElement)(
+ null,
+ (0, import_react27.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
+ (0, import_react27.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
@@ -17258,64 +17172,23 @@ diff --git a/client.js b/client.js
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
+ (0, import_react27.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
+ )
- );
-- const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
-- const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
-- const browseView = listing ? (0, import_react14.createElement)(
++ );
+}
+function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
+ const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
+ const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
+ return (0, import_react27.createElement)(
- "div",
-- { key: `browse:${listing.path}`, className: "bt-folder-view" },
-- (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react14.createElement)(ChevronLeftIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
-- (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
-- parent ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
-- inside.map((entry) => (0, import_react14.createElement)(
-- "button",
-- { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
-- (0, import_react14.createElement)(FolderIcon),
-- (0, import_react14.createElement)("span", { className: "bt-menu-label" }, entry.name),
-- (0, import_react14.createElement)(ChevronRightIcon)
-- )),
-- inside.length === 0 ? (0, import_react14.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
-- listing.truncated ? (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
-- (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
-- (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
-- ) : null;
-- const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
-- return (0, import_react14.createElement)(
-- import_react14.Fragment,
-- null,
-- (0, import_react14.createElement)(
-- "button",
-- {
-- ref: button,
-- type: "button",
-- className: "bt-model-trigger",
-- "data-wide": wide || void 0,
-- "aria-haspopup": "menu",
-- "aria-expanded": isOpen,
-- "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
-- title: value,
-- onClick: toggle
-- },
-- (0, import_react14.createElement)(
++ "div",
+ { className: "bt-connector" },
+ (0, import_react27.createElement)(
+ "div",
+ { className: "bt-connector-head" },
+ (0, import_react27.createElement)(
- "span",
-- { className: "bt-model-copy" },
-- (0, import_react14.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
-- value ? (0, import_react14.createElement)("span", { className: "bt-model-provider" }, value) : null
++ "span",
+ { className: "bt-connector-text" },
+ (0, import_react27.createElement)("span", { className: "bt-connector-name" }, item.name),
+ (0, import_react27.createElement)("span", { className: "bt-connector-status" }, secondary)
- ),
-- (0, import_react14.createElement)(Caret)
++ ),
+ (0, import_react27.createElement)(
+ "span",
+ { className: "bt-connector-actions" },
@@ -17359,7 +17232,26 @@ diff --git a/client.js b/client.js
+ { className: "bt-set-section" },
+ (0, import_react27.createElement)("h3", null, t("Shared capabilities")),
+ (0, import_react27.createElement)(
-+ "div",
+ "div",
+- { key: kind, className: "bt-task-when bt-veil" },
+- kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
+- kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", {
+- key: day,
+- type: "button",
+- className: "bt-task-day",
+- "aria-pressed": weekdays.includes(day),
+- onClick: () => toggleDay(day)
+- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
+- kind === "daily" || kind === "weekly" ? (0, import_react15.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
+- kind === "every" ? (0, import_react15.createElement)(
+- import_react15.Fragment,
+- null,
+- (0, import_react15.createElement)("span", { className: "bt-task-every" }, t("Every")),
+- (0, import_react15.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
+- (0, import_react15.createElement)(
+- "select",
+- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
+- UNITS.map(([id, , label]) => (0, import_react15.createElement)("option", { key: id, value: id }, t(label)))
+ { className: "bt-set-card bt-connectors-card" },
+ items.length === 0 ? (0, import_react27.createElement)("div", { className: "bt-set-row" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react27.createElement)(
+ "div",
@@ -17369,9 +17261,10 @@ diff --git a/client.js b/client.js
+ { className: "bt-set-copy" },
+ (0, import_react27.createElement)("span", { className: "bt-bs-label" }, item.name),
+ (0, import_react27.createElement)("span", { className: "bt-set-hint" }, item.description || (item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill")))
-+ )
+ )
+- ) : null
+ ))
-+ )
+ )
+ );
+ }
+ return (0, import_react27.createElement)(
@@ -17382,15 +17275,10 @@ diff --git a/client.js b/client.js
+ "div",
+ { className: "bt-actions" },
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
- ),
-- shownPlace ? (0, import_react_dom7.createPortal)((0, import_react14.createElement)(
++ ),
+ draft ? (0, import_react27.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
+ (0, import_react27.createElement)(
- "div",
-- { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
-- shownView,
-- problem ? (0, import_react14.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
-- ), document.body) : null
++ "div",
+ { className: "bt-set-card bt-connectors-card" },
+ items.length === 0 ? (0, import_react27.createElement)("div", { className: "bt-set-row" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react27.createElement)(CapabilityRow, {
+ key: item.name,
@@ -17418,31 +17306,9 @@ diff --git a/client.js b/client.js
+ on: attached.has(item.name),
+ onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled))
+ }))
- );
- }
--var FOLDER_CSS = `
--.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
--.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
--.bt-folder-copy .bt-menu-label{flex:none}
--.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
--.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
--.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
--.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
--.bt-folder-input:focus{border-color:var(--bt-accent)}
--.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
--.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
--.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
--@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
--`;
-
--// src/client/schedules.js
--var import_react15 = require("react");
--var POLL_MS = 3e4;
--var shared = null;
--var listeners = /* @__PURE__ */ new Set();
--function publish(view) {
-- shared = view;
-- for (const listener of listeners) listener(view);
++ );
++}
++
+// src/client/memory-empty.js
+var blockedPaths = (view) => [...new Set((view?.blocked ?? []).map((item) => typeof item?.path === "string" ? item.path : "").filter((path) => path !== ""))];
+var blockedSentence = (paths) => t("The memory folder {path} is not a plain directory, so it cannot be read.", { path: paths.join(", ") });
@@ -17454,20 +17320,7 @@ diff --git a/client.js b/client.js
+ const paths = blockedPaths(view);
+ if (paths.length === 0) return null;
+ return blockedSentence(paths);
- }
--function useSchedules(actions) {
-- const [view, setView] = (0, import_react15.useState)(shared);
-- (0, import_react15.useEffect)(() => {
-- listeners.add(setView);
-- let stopped = false;
-- let timer;
-- const load = () => actions.schedules?.().then((next) => {
-- if (!stopped) publish(next);
-- }).catch(() => {
-- }).finally(() => {
-- if (!stopped) timer = setTimeout(load, POLL_MS);
-- });
-- load();
++}
+
+// src/client/memory-panel.js
+var import_react28 = require("react");
@@ -17506,33 +17359,19 @@ diff --git a/client.js b/client.js
+ if (!stop) noteFailure(botId, hostText(error?.message ?? String(error)));
+ });
+ }
- return () => {
-- stopped = true;
-- clearTimeout(timer);
-- listeners.delete(setView);
++ return () => {
+ stop = true;
+ countListeners.delete(listener);
- };
-- }, []);
-- return view;
++ };
+ }, [botId]);
+ return state;
- }
--var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
--var clock2 = (time) => String(time ?? "").slice(0, 5);
--var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
--function dayAndTime(ms) {
-- const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
-- return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
++}
+function memoryCountText({ count, failure }) {
+ if (failure) return t("The memory could not be read: {reason}", { reason: failure });
+ if (count === null) return t("Loading…");
+ if (count === 0) return t("Nothing saved yet");
-+ return count === 1 ? t("1 entry") : t("{count} entries", { count });
- }
--function weekdayNames(days) {
-- const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
-- return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
++ return count === 1 ? t("1 entry") : t("{count} entries", { count });
++}
+function MemoryRow({ bot, actions }) {
+ const { count, failure } = useMemoryCount(bot.id, actions);
+ const label = memoryCountText({ count, failure });
@@ -17546,14 +17385,12 @@ diff --git a/client.js b/client.js
+ (0, import_react28.createElement)(MemoryIcon),
+ (0, import_react28.createElement)("span", { className: "bt-mem-row-copy" }, label),
+ (0, import_react28.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react28.createElement)(ChevronRightIcon))
-+ ),
+ ),
+- error ? (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+- (0, import_react15.createElement)(
+ failure === null ? null : (0, import_react28.createElement)("div", { className: "bt-note", role: "status" }, label)
+ );
- }
--function everyText(seconds) {
-- if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
-- if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
-- return t("Every {count} minutes", { count: Math.round(seconds / 60) });
++}
+function ago(at, now) {
+ const seconds = Math.max(0, Math.round((now - at) / 1e3));
+ if (seconds < 60) return null;
@@ -17563,32 +17400,17 @@ diff --git a/client.js b/client.js
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return format.format(-hours, "hour");
+ return format.format(-Math.round(hours / 24), "day");
- }
--function timingText(task) {
-- const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
-- switch (task.kind) {
-- case "after":
-- case "at":
-- return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
-- case "every":
-- return everyText(task.everySeconds);
-- case "daily":
-- return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
-- case "weekly": {
-- const days = (task.weekdays ?? []).join();
-- const text = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
-- return `${text}${zone}`;
-- }
-- case "cron":
-- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
-- default:
-- return "";
++}
+function Skeleton({ label }) {
+ return (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-skel", "aria-busy": true },
+ [0, 1].map((block) => (0, import_react28.createElement)(
-+ "div",
+ "div",
+- { className: "bt-set-row bt-task-foot" },
+- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+- (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task"))
+- )
+ { key: block, className: "bt-mem-skel-block" },
+ (0, import_react28.createElement)("span", { className: "bt-mem-skel-head" }),
+ (0, import_react28.createElement)("span", null),
@@ -17596,8 +17418,10 @@ diff --git a/client.js b/client.js
+ (0, import_react28.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
+ )),
+ label ? (0, import_react28.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
-+ );
-+}
+ );
+ }
+-function AutomationOff() {
+- return (0, import_react15.createElement)(
+function Summary({ bot, view, regenerate }) {
+ if (view.entries.length === 0) {
+ const notice = memoryEmptyNotice(view);
@@ -17609,225 +17433,33 @@ diff --git a/client.js b/client.js
+ (0, import_react28.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
+ (0, import_react28.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
+ );
- }
++ }
+ const failed = view.error && !view.summarizing ? (0, import_react28.createElement)(
-+ "div",
-+ { className: "bt-mem-failed", role: "alert" },
-+ (0, import_react28.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
-+ (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
-+ ) : null;
-+ const refused = memoryBlockedNotice(view);
-+ const blocked = refused === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, refused);
-+ if (!view.summary) return (0, import_react28.createElement)(import_react28.Fragment, null, blocked, failed, failed ? null : (0, import_react28.createElement)(Skeleton, { label: t("Writing the summary…") }));
-+ return (0, import_react28.createElement)(
-+ import_react28.Fragment,
-+ null,
-+ blocked,
-+ failed,
-+ (0, import_react28.createElement)(
-+ "div",
-+ { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
-+ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react28.createElement)("h3", { key: index }, block.text) : (0, import_react28.createElement)("p", { key: index }, block.text))
-+ )
-+ );
- }
--var nextText = (task) => task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
--function TaskRow({ task, actions, onView, showBot, roster }) {
-- const [confirming, setConfirming] = (0, import_react15.useState)(false);
-- const [busy, setBusy] = (0, import_react15.useState)(false);
-- (0, import_react15.useEffect)(() => {
-+var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
-+ const key = `${entry.scope}:${entry.topic ?? ""}`;
-+ const group = groups.find((item) => item.key === key);
-+ if (group) group.entries.push(entry);
-+ else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
-+ return groups;
-+}, []);
-+function EntryRow({ entry, remove }) {
-+ const [confirming, setConfirming] = (0, import_react28.useState)(false);
-+ const [busy, setBusy] = (0, import_react28.useState)(false);
-+ (0, import_react28.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 3e3);
- return () => clearTimeout(timer);
- }, [confirming]);
-- const remove = () => {
-+ const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
-+ const meta = [entry.added, by].filter(Boolean).join(" · ");
-+ const click = async () => {
- if (!confirming) {
- setConfirming(true);
- return;
- }
- setBusy(true);
-- void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
-+ if (!await remove(entry)) setBusy(false);
- };
-- const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
-- return (0, import_react15.createElement)(
-- "div",
-- { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 },
-- showBot ? (0, import_react15.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
-- (0, import_react15.createElement)(
-- "span",
-- { className: "bt-set-copy" },
-- (0, import_react15.createElement)("span", { className: "bt-task-title" }, task.title),
-- (0, import_react15.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
-- task.prompt && task.prompt !== task.title ? (0, import_react15.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null
-+ return (0, import_react28.createElement)(
-+ "li",
-+ { className: "bt-mem-entry", "data-busy": busy || void 0 },
-+ (0, import_react28.createElement)(
-+ "div",
-+ { className: "bt-mem-entry-copy" },
-+ (0, import_react28.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
-+ meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
- ),
-- (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)),
-- (0, import_react15.createElement)("button", {
-+ remove ? (0, import_react28.createElement)("button", {
- type: "button",
-- className: "bt-mini bt-task-remove",
-+ className: "bt-mem-remove",
- "data-confirm": confirming || void 0,
- disabled: busy,
-- "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
-- title: confirming ? void 0 : t("Delete task"),
-- onClick: remove
-- }, confirming ? t("Delete") : (0, import_react15.createElement)(TrashIcon))
-- );
--}
--var pad = (number) => String(number).padStart(2, "0");
--function inAnHour() {
-- const date = new Date(Date.now() + 36e5);
-- date.setMinutes(0, 0, 0);
-- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
--}
--var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
--var UNITS = [["minutes", 1, "minutes"], ["hours", 60, "hours"], ["days", 1440, "days"]];
--function TaskForm({ roster, actions, start, onDone, onCancel }) {
-- const [target, setTarget] = (0, import_react15.useState)(start ?? roster.bots[0]?.id ?? "");
-- const [title, setTitle] = (0, import_react15.useState)("");
-- const [prompt, setPrompt] = (0, import_react15.useState)("");
-- const [kind, setKind] = (0, import_react15.useState)("daily");
-- const [at, setAt] = (0, import_react15.useState)(inAnHour);
-- const [time, setTime] = (0, import_react15.useState)("09:00");
-- const [weekdays, setWeekdays] = (0, import_react15.useState)([1, 2, 3, 4, 5]);
-- const [count, setCount] = (0, import_react15.useState)(1);
-- const [unit, setUnit] = (0, import_react15.useState)("hours");
-- const [busy, setBusy] = (0, import_react15.useState)(false);
-- const [error, setError] = (0, import_react15.useState)("");
-- const timing = () => {
-- if (kind === "at") return { kind, at: new Date(at).toISOString() };
-- if (kind === "every") return { kind, minutes: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
-- return { kind, time, timeZone: localZone(), ...kind === "weekly" ? { weekdays } : {} };
-- };
-- const submit = (event) => {
-- event.preventDefault();
-- if (busy) return;
-- setBusy(true);
-- setError("");
-- let when3;
-- try {
-- when3 = timing();
-- } catch {
-- setError(t("Pick a date and time"));
-- setBusy(false);
-- return;
-- }
-- void actions.scheduleCreate({ target, title, prompt, when: when3 }).then(onDone, (failure) => {
-- setError(hostText(failure?.message ?? String(failure)));
-- setBusy(false);
-- });
-- };
-- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
-- return (0, import_react15.createElement)(
-- "form",
-- { className: "bt-set-card bt-task-form", onSubmit: submit },
-- (0, import_react15.createElement)(
-- "label",
-- { className: "bt-set-row bt-bs-row" },
-- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
-- (0, import_react15.createElement)(
-- "select",
-- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
-- (0, import_react15.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react15.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
-- roster.rooms.length > 0 ? (0, import_react15.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react15.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
-- )
-- ),
-- (0, import_react15.createElement)(
-- "label",
-- { className: "bt-set-row bt-bs-row" },
-- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
-- (0, import_react15.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
-- ),
-- (0, import_react15.createElement)(
-- "label",
-- { className: "bt-set-row bt-bs-row bt-bs-stack" },
-- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
-- (0, import_react15.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
-- ),
-- (0, import_react15.createElement)(
-- "div",
-- { className: "bt-set-row bt-bs-row bt-bs-stack" },
-- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("When"))),
-- (0, import_react15.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react15.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
-- (0, import_react15.createElement)(
-- "div",
-- { key: kind, className: "bt-task-when bt-veil" },
-- kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
-- kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", {
-- key: day,
-- type: "button",
-- className: "bt-task-day",
-- "aria-pressed": weekdays.includes(day),
-- onClick: () => toggleDay(day)
-- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
-- kind === "daily" || kind === "weekly" ? (0, import_react15.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
-- kind === "every" ? (0, import_react15.createElement)(
-- import_react15.Fragment,
-- null,
-- (0, import_react15.createElement)("span", { className: "bt-task-every" }, t("Every")),
-- (0, import_react15.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
-- (0, import_react15.createElement)(
-- "select",
-- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
-- UNITS.map(([id, , label]) => (0, import_react15.createElement)("option", { key: id, value: id }, t(label)))
-- )
-- ) : null
-- )
-- ),
-- error ? (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
-- (0, import_react15.createElement)(
-- "div",
-- { className: "bt-set-row bt-task-foot" },
-- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
-- (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task"))
-- )
-+ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
-+ title: confirming ? void 0 : t("Remove from memory"),
-+ onClick: click
-+ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) : null
- );
- }
--function AutomationOff() {
-- return (0, import_react15.createElement)(
-+function Entries({ bot, view, remove }) {
-+ const groups = groupEntries(view.entries);
-+ const refused = memoryBlockedNotice(view);
-+ const sections = [
-+ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
-+ { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
-+ ];
-+ return (0, import_react28.createElement)(
"div",
- { className: "bt-set-card bt-task-off" },
- (0, import_react15.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react15.createElement)(ClockIcon)),
- (0, import_react15.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
- (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")),
- (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings"))
-- );
--}
++ { className: "bt-mem-failed", role: "alert" },
++ (0, import_react28.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
++ (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
++ ) : null;
++ const refused = memoryBlockedNotice(view);
++ const blocked = refused === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, refused);
++ if (!view.summary) return (0, import_react28.createElement)(import_react28.Fragment, null, blocked, failed, failed ? null : (0, import_react28.createElement)(Skeleton, { label: t("Writing the summary…") }));
++ return (0, import_react28.createElement)(
++ import_react28.Fragment,
++ null,
++ blocked,
++ failed,
++ (0, import_react28.createElement)(
++ "div",
++ { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
++ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react28.createElement)("h3", { key: index }, block.text) : (0, import_react28.createElement)("p", { key: index }, block.text))
++ )
+ );
+ }
-function SchedulesPage({ roster, actions, start }) {
- const view = useSchedules(actions);
- const [adding, setAdding] = (0, import_react15.useState)(Boolean(start));
@@ -17836,7 +17468,31 @@ diff --git a/client.js b/client.js
- const shown = (next) => {
- publish(next);
- setAdding(false);
-- };
++var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
++ const key = `${entry.scope}:${entry.topic ?? ""}`;
++ const group = groups.find((item) => item.key === key);
++ if (group) group.entries.push(entry);
++ else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
++ return groups;
++}, []);
++function EntryRow({ entry, remove }) {
++ const [confirming, setConfirming] = (0, import_react28.useState)(false);
++ const [busy, setBusy] = (0, import_react28.useState)(false);
++ (0, import_react28.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(false), 3e3);
++ return () => clearTimeout(timer);
++ }, [confirming]);
++ const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
++ const meta = [entry.added, by].filter(Boolean).join(" · ");
++ const click = async () => {
++ if (!confirming) {
++ setConfirming(true);
++ return;
++ }
++ setBusy(true);
++ if (!await remove(entry)) setBusy(false);
+ };
- const byChat = [
- ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
- ...roster.rooms.map((room2) => [room2.id, (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
@@ -17844,31 +17500,13 @@ diff --git a/client.js b/client.js
- return [
- adding ? (0, import_react15.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react15.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setAdding(true) }, (0, import_react15.createElement)(PlusIcon), t("New task"))),
- byChat.length === 0 && !adding ? (0, import_react15.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react15.createElement)(
-+ { className: "bt-mem-entries" },
-+ refused === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, refused),
-+ sections.map((section) => (0, import_react28.createElement)(
- "section",
+- "section",
- { key: id, className: "bt-set-section" },
- (0, import_react15.createElement)("h3", { className: "bt-task-head" }, avatar, name),
- (0, import_react15.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })))
-+ { key: section.scope, className: "bt-mem-section" },
-+ (0, import_react28.createElement)(
-+ "div",
-+ { className: "bt-mem-section-head" },
-+ (0, import_react28.createElement)("h3", null, section.title),
-+ section.hint ? (0, import_react28.createElement)("span", null, section.hint) : null,
-+ (0, import_react28.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
-+ ),
-+ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react28.createElement)(
-+ import_react28.Fragment,
-+ { key: group.key },
-+ group.topic ? (0, import_react28.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
-+ (0, import_react28.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react28.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
-+ )) : (0, import_react28.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
- ))
+- ))
- ];
-+ );
- }
+-}
-function BotSchedules({ bot, roster, actions }) {
- const view = useSchedules(actions);
- if (view === null) return null;
@@ -17878,17 +17516,9 @@ diff --git a/client.js b/client.js
- { className: "bt-set-section" },
- (0, import_react15.createElement)("h3", null, t("Scheduled tasks")),
- (0, import_react15.createElement)(
-+function changeLine(change) {
-+ const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
-+ if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
-+ if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
-+ if (change.op === "edit") return t("Updated: {text}", { text: text2 });
-+ return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
-+}
-+function Reply({ answer, dismiss }) {
+ return (0, import_react28.createElement)(
-+ "div",
-+ { className: "bt-mem-reply", role: "status" },
++ "li",
++ { className: "bt-mem-entry", "data-busy": busy || void 0 },
+ (0, import_react28.createElement)(
"div",
- { className: "bt-set-card" },
@@ -17901,28 +17531,35 @@ diff --git a/client.js b/client.js
- ...tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish })),
- (0, import_react15.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react15.createElement)(PlusIcon), t("New task"))
- ]
-+ { className: "bt-mem-reply-head" },
-+ (0, import_react28.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
-+ answer.pending ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react28.createElement)(CloseIcon))
+- )
++ { className: "bt-mem-entry-copy" },
++ (0, import_react28.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
++ meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
+ ),
-+ answer.pending ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react28.createElement)(
-+ import_react28.Fragment,
-+ null,
-+ answer.reply ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
-+ answer.changes.length > 0 ? (0, import_react28.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react28.createElement)(
-+ "li",
-+ { key: index, "data-ok": change.ok || void 0 },
-+ change.ok ? (0, import_react28.createElement)(CheckIcon) : (0, import_react28.createElement)(CloseIcon),
-+ (0, import_react28.createElement)("span", null, changeLine(change))
-+ ))) : null
- )
++ remove ? (0, import_react28.createElement)("button", {
++ type: "button",
++ className: "bt-mem-remove",
++ "data-confirm": confirming || void 0,
++ disabled: busy,
++ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
++ title: confirming ? void 0 : t("Remove from memory"),
++ onClick: click
++ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon)) : null
);
}
-function ScheduleSummary({ bot, actions }) {
- const view = useSchedules(actions);
- const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
- return (0, import_react15.createElement)(
-- "div",
++function Entries({ bot, view, remove }) {
++ const groups = groupEntries(view.entries);
++ const refused = memoryBlockedNotice(view);
++ const sections = [
++ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
++ { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
++ ];
++ return (0, import_react28.createElement)(
+ "div",
- null,
- (0, import_react15.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
- tasks.length === 0 ? (0, import_react15.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page in Harness settings.") : t("No scheduled tasks yet. Add one")) : (0, import_react15.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react15.createElement)(
@@ -17932,39 +17569,26 @@ diff --git a/client.js b/client.js
- (0, import_react15.createElement)("span", { className: "bt-task-line-title" }, task.title),
- (0, import_react15.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
- )))
-- );
-+function useMemoryView(botId, actions) {
-+ const [view, setView] = (0, import_react28.useState)(null);
-+ const [error, setError] = (0, import_react28.useState)("");
-+ const timer = (0, import_react28.useRef)(0);
-+ const latest = (0, import_react28.useRef)(0);
-+ const alive = (0, import_react28.useRef)(true);
-+ const show = (0, import_react28.useCallback)((next) => {
-+ setView(next);
-+ setError("");
-+ noteCount(botId, next.entries.length);
-+ }, [botId]);
-+ const load = (0, import_react28.useCallback)(async (flags = {}) => {
-+ clearTimeout(timer.current);
-+ const ticket = ++latest.current;
-+ try {
-+ const next = await actions.memoryView(botId, flags);
-+ if (!alive.current || ticket !== latest.current) return;
-+ show(next);
-+ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
-+ } catch (failure) {
-+ if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
-+ }
-+ }, [botId]);
-+ (0, import_react28.useEffect)(() => {
-+ alive.current = true;
-+ void load({ summarize: true });
-+ return () => {
-+ alive.current = false;
-+ clearTimeout(timer.current);
-+ };
-+ }, [botId]);
-+ return { view, error, load, show };
++ { className: "bt-mem-entries" },
++ refused === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, refused),
++ sections.map((section) => (0, import_react28.createElement)(
++ "section",
++ { key: section.scope, className: "bt-mem-section" },
++ (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-section-head" },
++ (0, import_react28.createElement)("h3", null, section.title),
++ section.hint ? (0, import_react28.createElement)("span", null, section.hint) : null,
++ (0, import_react28.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
++ ),
++ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react28.createElement)(
++ import_react28.Fragment,
++ { key: group.key },
++ group.topic ? (0, import_react28.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
++ (0, import_react28.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react28.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
++ )) : (0, import_react28.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
++ ))
+ );
}
-var SCHEDULE_CSS = `
-.bt-task{gap:10px}
@@ -18033,22 +17657,83 @@ diff --git a/client.js b/client.js
- spellCheck: false,
- onChange: (event) => onChange(event.target.value)
- })
-- );
-+function useNow3(every) {
-+ const [now, setNow] = (0, import_react28.useState)(Date.now());
-+ (0, import_react28.useEffect)(() => {
-+ const id = setInterval(() => setNow(Date.now()), every);
-+ return () => clearInterval(id);
-+ }, [every]);
-+ return now;
++function changeLine(change) {
++ const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
++ if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
++ if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
++ if (change.op === "edit") return t("Updated: {text}", { text: text2 });
++ return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
++}
++function Reply({ answer, dismiss }) {
++ return (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-reply", role: "status" },
++ (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-reply-head" },
++ (0, import_react28.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
++ answer.pending ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react28.createElement)(CloseIcon))
++ ),
++ answer.pending ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react28.createElement)(
++ import_react28.Fragment,
++ null,
++ answer.reply ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
++ answer.changes.length > 0 ? (0, import_react28.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react28.createElement)(
++ "li",
++ { key: index, "data-ok": change.ok || void 0 },
++ change.ok ? (0, import_react28.createElement)(CheckIcon) : (0, import_react28.createElement)(CloseIcon),
++ (0, import_react28.createElement)("span", null, changeLine(change))
++ ))) : null
++ )
+ );
}
-function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
- const [error, setError] = (0, import_react16.useState)("");
- const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
- const submit = async (event) => {
- event.preventDefault();
-- setError("");
++function useMemoryView(botId, actions) {
++ const [view, setView] = (0, import_react28.useState)(null);
++ const [error, setError] = (0, import_react28.useState)("");
++ const timer = (0, import_react28.useRef)(0);
++ const latest = (0, import_react28.useRef)(0);
++ const alive = (0, import_react28.useRef)(true);
++ const show = (0, import_react28.useCallback)((next) => {
++ setView(next);
+ setError("");
- let payload;
++ noteCount(botId, next.entries.length);
++ }, [botId]);
++ const load = (0, import_react28.useCallback)(async (flags = {}) => {
++ clearTimeout(timer.current);
++ const ticket = ++latest.current;
++ try {
++ const next = await actions.memoryView(botId, flags);
++ if (!alive.current || ticket !== latest.current) return;
++ show(next);
++ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
++ } catch (failure) {
++ if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
++ }
++ }, [botId]);
++ (0, import_react28.useEffect)(() => {
++ alive.current = true;
++ void load({ summarize: true });
++ return () => {
++ alive.current = false;
++ clearTimeout(timer.current);
++ };
++ }, [botId]);
++ return { view, error, load, show };
++}
++function useNow3(every) {
++ const [now, setNow] = (0, import_react28.useState)(Date.now());
++ (0, import_react28.useEffect)(() => {
++ const id = setInterval(() => setNow(Date.now()), every);
++ return () => clearInterval(id);
++ }, [every]);
++ return now;
++}
+function MemoryDialog({ bot, actions, roster, leaving = false }) {
+ const { view, error, load, show } = useMemoryView(bot.id, actions);
+ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== "";
@@ -18575,8 +18260,26 @@ diff --git a/client.js b/client.js
- { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
- summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react17.createElement)("h3", { key: index }, block.text) : (0, import_react17.createElement)("p", { key: index }, block.text))
- )
-- );
--}
++ (0, import_react30.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react30.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react30.createElement)("button", {
++ key: id,
++ type: "button",
++ className: "bt-shape",
++ "aria-label": t(shapeOf(id).label),
++ title: t(shapeOf(id).label),
++ "aria-pressed": !hasPhoto && look.shape === id,
++ onClick: () => onShape(id)
++ }, (0, import_react30.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react30.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
++ (0, import_react30.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react30.createElement)("button", {
++ key: color,
++ type: "button",
++ className: "bt-color",
++ "aria-label": t(capitalize(color)),
++ title: t(capitalize(color)),
++ "aria-pressed": look.color === color,
++ onClick: () => onColor(color)
++ }, (0, import_react30.createElement)("span", { style: { background: paintCss(color) } }))))
+ );
+ }
-var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
- const key = `${entry.scope}:${entry.topic ?? ""}`;
- const group = groups.find((item) => item.key === key);
@@ -18601,51 +18304,6 @@ diff --git a/client.js b/client.js
- }
- setBusy(true);
- if (!await remove(entry)) setBusy(false);
-- };
-- return (0, import_react17.createElement)(
-- "li",
-- { className: "bt-mem-entry", "data-busy": busy || void 0 },
-- (0, import_react17.createElement)(
-- "div",
-- { className: "bt-mem-entry-copy" },
-- (0, import_react17.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
-- meta ? (0, import_react17.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
-- ),
-- (0, import_react17.createElement)("button", {
-+ (0, import_react30.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react30.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react30.createElement)("button", {
-+ key: id,
- type: "button",
-- className: "bt-mem-remove",
-- "data-confirm": confirming || void 0,
-- disabled: busy,
-- "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
-- title: confirming ? void 0 : t("Remove from memory"),
-- onClick: click
-- }, confirming ? t("Remove") : (0, import_react17.createElement)(TrashIcon))
-+ className: "bt-shape",
-+ "aria-label": t(shapeOf(id).label),
-+ title: t(shapeOf(id).label),
-+ "aria-pressed": !hasPhoto && look.shape === id,
-+ onClick: () => onShape(id)
-+ }, (0, import_react30.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react30.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
-+ (0, import_react30.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react30.createElement)("button", {
-+ key: color,
-+ type: "button",
-+ className: "bt-color",
-+ "aria-label": t(capitalize(color)),
-+ title: t(capitalize(color)),
-+ "aria-pressed": look.color === color,
-+ onClick: () => onColor(color)
-+ }, (0, import_react30.createElement)("span", { style: { background: paintCss(color) } }))))
- );
- }
--function Entries({ bot, view, remove }) {
-- const groups = groupEntries(view.entries);
-- const sections = [
-- { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
-- { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
-- ];
-- return (0, import_react17.createElement)(
+function AvatarEditor({ bot, actions, run, onClose }) {
+ const look = lookOf(bot);
+ const [tab, setTab] = (0, import_react30.useState)(look.image ? "upload" : "bot");
@@ -18654,7 +18312,11 @@ diff --git a/client.js b/client.js
+ const fileRef = (0, import_react30.useRef)(null);
+ const upload = (file) => {
+ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
-+ };
+ };
+- return (0, import_react17.createElement)(
+- "li",
+- { className: "bt-mem-entry", "data-busy": busy || void 0 },
+- (0, import_react17.createElement)(
+ (0, import_react30.useEffect)(() => {
+ const onDown = (event) => {
+ if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
@@ -18688,51 +18350,20 @@ diff --git a/client.js b/client.js
+ const hasPhoto = Boolean(look.image);
+ const glide = useGlide(tab);
+ return (0, import_react30.createElement)(
- "div",
-- { className: "bt-mem-entries" },
-- sections.map((section) => (0, import_react17.createElement)(
-- "section",
-- { key: section.scope, className: "bt-mem-section" },
-- (0, import_react17.createElement)(
++ "div",
+ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
+ (0, import_react30.createElement)(
-+ "div",
+ "div",
+- { className: "bt-mem-entry-copy" },
+- (0, import_react17.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
+- meta ? (0, import_react17.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
+ { className: "bt-avedit-top" },
+ (0, import_react30.createElement)(
- "div",
-- { className: "bt-mem-section-head" },
-- (0, import_react17.createElement)("h3", null, section.title),
-- section.hint ? (0, import_react17.createElement)("span", null, section.hint) : null,
-- (0, import_react17.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
++ "div",
+ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
+ (0, import_react30.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react30.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
- ),
-- groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react17.createElement)(
-- import_react17.Fragment,
-- { key: group.key },
-- group.topic ? (0, import_react17.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
-- (0, import_react17.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react17.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
-- )) : (0, import_react17.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
-- ))
-- );
--}
--function changeLine(change) {
-- const text = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
-- if (!change.ok) return t("Not changed: {text} ({reason})", { text, reason: hostText(change.error ?? "") });
-- if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text }) : t("Removed: {text}", { text });
-- if (change.op === "edit") return t("Updated: {text}", { text });
-- return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text });
--}
--function Reply({ answer, dismiss }) {
-- return (0, import_react17.createElement)(
-- "div",
-- { className: "bt-mem-reply", role: "status" },
-- (0, import_react17.createElement)(
-- "div",
-- { className: "bt-mem-reply-head" },
-- (0, import_react17.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
-- answer.pending ? null : (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react17.createElement)(CloseIcon))
++ ),
+ (0, import_react30.createElement)("button", {
+ type: "button",
+ className: "bt-avedit-tab bt-avedit-reset",
@@ -18740,17 +18371,30 @@ diff --git a/client.js b/client.js
+ onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
+ }, t("Reset"))
),
-- answer.pending ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react17.createElement)(
-- import_react17.Fragment,
-- null,
-- answer.reply ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
-- answer.changes.length > 0 ? (0, import_react17.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react17.createElement)(
-- "li",
-- { key: index, "data-ok": change.ok || void 0 },
-- change.ok ? (0, import_react17.createElement)(CheckIcon) : (0, import_react17.createElement)(CloseIcon),
-- (0, import_react17.createElement)("span", null, changeLine(change))
-- ))) : null
-- )
+- (0, import_react17.createElement)("button", {
+- type: "button",
+- className: "bt-mem-remove",
+- "data-confirm": confirming || void 0,
+- disabled: busy,
+- "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
+- title: confirming ? void 0 : t("Remove from memory"),
+- onClick: click
+- }, confirming ? t("Remove") : (0, import_react17.createElement)(TrashIcon))
+- );
+-}
+-function Entries({ bot, view, remove }) {
+- const groups = groupEntries(view.entries);
+- const sections = [
+- { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
+- { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
+- ];
+- return (0, import_react17.createElement)(
+- "div",
+- { className: "bt-mem-entries" },
+- sections.map((section) => (0, import_react17.createElement)(
+- "section",
+- { key: section.scope, className: "bt-mem-section" },
+- (0, import_react17.createElement)(
+ tab === "bot" ? (0, import_react30.createElement)(CharacterGrid, {
+ look,
+ hasPhoto,
@@ -18776,11 +18420,22 @@ diff --git a/client.js b/client.js
+ hasPhoto ? (0, import_react30.createElement)("img", { src: look.image, alt: "" }) : null,
+ (0, import_react30.createElement)("div", null, t("Drag, drop, or paste an image")),
+ (0, import_react30.createElement)(
-+ "div",
+ "div",
+- { className: "bt-mem-section-head" },
+- (0, import_react17.createElement)("h3", null, section.title),
+- section.hint ? (0, import_react17.createElement)("span", null, section.hint) : null,
+- (0, import_react17.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
+ { className: "bt-actions", style: { justifyContent: "center" } },
+ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
+ hasPhoto ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
-+ ),
+ ),
+- groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react17.createElement)(
+- import_react17.Fragment,
+- { key: group.key },
+- group.topic ? (0, import_react17.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
+- (0, import_react17.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react17.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
+- )) : (0, import_react17.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
+- ))
+ (0, import_react30.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
@@ -18789,6 +18444,47 @@ diff --git a/client.js b/client.js
+ ) : null
);
}
+-function changeLine(change) {
+- const text = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
+- if (!change.ok) return t("Not changed: {text} ({reason})", { text, reason: hostText(change.error ?? "") });
+- if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text }) : t("Removed: {text}", { text });
+- if (change.op === "edit") return t("Updated: {text}", { text });
+- return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text });
+-}
+-function Reply({ answer, dismiss }) {
+- return (0, import_react17.createElement)(
+- "div",
+- { className: "bt-mem-reply", role: "status" },
+- (0, import_react17.createElement)(
+- "div",
+- { className: "bt-mem-reply-head" },
+- (0, import_react17.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
+- answer.pending ? null : (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react17.createElement)(CloseIcon))
+- ),
+- answer.pending ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react17.createElement)(
+- import_react17.Fragment,
+- null,
+- answer.reply ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
+- answer.changes.length > 0 ? (0, import_react17.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react17.createElement)(
+- "li",
+- { key: index, "data-ok": change.ok || void 0 },
+- change.ok ? (0, import_react17.createElement)(CheckIcon) : (0, import_react17.createElement)(CloseIcon),
+- (0, import_react17.createElement)("span", null, changeLine(change))
+- ))) : null
++function MemoryCard({ bot, actions }) {
++ const { count, failure } = useMemoryCount(bot.id, actions);
++ const label = memoryCountText({ count, failure });
++ return (0, import_react30.createElement)(
++ Card,
++ { title: t("Memory") },
++ (0, import_react30.createElement)(
++ "button",
++ { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label)),
++ (0, import_react30.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react30.createElement)(ChevronRightIcon))
+ )
+ );
+ }
-function useMemoryView(botId, actions) {
- const [view, setView] = (0, import_react17.useState)(null);
- const [error, setError] = (0, import_react17.useState)("");
@@ -18829,20 +18525,7 @@ diff --git a/client.js b/client.js
- return () => clearInterval(id);
- }, [every]);
- return now;
-+function MemoryCard({ bot, actions }) {
-+ const { count, failure } = useMemoryCount(bot.id, actions);
-+ const label = memoryCountText({ count, failure });
-+ return (0, import_react30.createElement)(
-+ Card,
-+ { title: t("Memory") },
-+ (0, import_react30.createElement)(
-+ "button",
-+ { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
-+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label)),
-+ (0, import_react30.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react30.createElement)(ChevronRightIcon))
-+ )
-+ );
- }
+-}
-function MemoryDialog({ bot, actions, leaving = false }) {
- const { view, error, load, show } = useMemoryView(bot.id, actions);
- const [page, setPage] = (0, import_react17.useState)("summary");
@@ -19137,10 +18820,10 @@ diff --git a/client.js b/client.js
+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
+ ),
+ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
- )
- )
- );
- }
++ )
++ )
++ );
++}
+var BOT_SETTINGS_CSS = `
+.bt-bs{display:flex;flex-direction:column;gap:18px}
+.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px}
@@ -19174,42 +18857,23 @@ diff --git a/client.js b/client.js
+.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease}
+.bt-switch[aria-checked=true]{background:var(--bt-accent)}
+.bt-switch:disabled{opacity:.45;cursor:default}
-+.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
-+.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
-+.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
-+.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
-+.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
-+.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
-+@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
-+/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
-+ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
-+ keeps its own words. Nothing is clipped to fit a width that is not there. */
-+@container bt-panel (max-width:300px){
-+ .bt-bs-row{flex-wrap:wrap;row-gap:6px}
-+ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
-+ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
-+}
-+`;
-
--// src/client/bot-settings.js
--var import_react18 = require("react");
--var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
--async function avatarImage(file) {
-- if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
-- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
-- const bitmap = await createImageBitmap(file);
-- const side = 160;
-- const canvas = document.createElement("canvas");
-- canvas.width = side;
-- canvas.height = side;
-- const context = canvas.getContext("2d");
-- context.imageSmoothingQuality = "high";
-- const crop = Math.min(bitmap.width, bitmap.height);
-- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
-- bitmap.close?.();
-- for (const quality of [0.9, 0.75, 0.6]) {
-- const url = canvas.toDataURL("image/webp", quality);
-- if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
++.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
++.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
++.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
++.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
++.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
++.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
++@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
++/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
++ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
++ keeps its own words. Nothing is clipped to fit a width that is not there. */
++@container bt-panel (max-width:300px){
++ .bt-bs-row{flex-wrap:wrap;row-gap:6px}
++ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
++ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
++}
++`;
++
+// src/client/group-settings.js
+var import_react31 = require("react");
+var ROOM_MODES = [
@@ -19244,7 +18908,62 @@ diff --git a/client.js b/client.js
+ const bot = faceSeatBot(seat, roster);
+ if (!bot) continue;
+ marks.push((0, import_react31.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
- }
++ }
++ if (marks.length === 0) return [(0, import_react31.createElement)(PersonMark, { key: "person", size, name: person?.name })];
++ return marks;
++}
++function faceRoomCount(room2, roster) {
++ const people2 = facePeople(room2, roster).length;
++ const seats = faceSeats(room2, roster).length;
++ const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
++ const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
++ return `${persons} · ${bots}`;
++}
++var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
++var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
++function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
++ const bots = person.seats.flatMap((seat, index) => {
++ const name = seat.local && onOpenBot ? (0, import_react31.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react31.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
++ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react31.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
++ return index === 0 ? marked : [(0, import_react31.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
++ });
++ return (0, import_react31.createElement)(
++ "div",
++ { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-gs-who bt-face-person" },
++ (0, import_react31.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-face-person-text" },
++ (0, import_react31.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
++ (0, import_react31.createElement)("span", { className: "bt-face-bots" }, bots)
+ )
+ )
+ );
+ }
+-
+-// src/client/bot-settings.js
+-var import_react18 = require("react");
+-var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
+-async function avatarImage(file) {
+- if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
+- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
+- const bitmap = await createImageBitmap(file);
+- const side = 160;
+- const canvas = document.createElement("canvas");
+- canvas.width = side;
+- canvas.height = side;
+- const context = canvas.getContext("2d");
+- context.imageSmoothingQuality = "high";
+- const crop = Math.min(bitmap.width, bitmap.height);
+- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
+- bitmap.close?.();
+- for (const quality of [0.9, 0.75, 0.6]) {
+- const url = canvas.toDataURL("image/webp", quality);
+- if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
+- }
- const png = canvas.toDataURL("image/png");
- if (png.length < 15e4) return png;
- throw new Error(t("That image is too large"));
@@ -19258,10 +18977,30 @@ diff --git a/client.js b/client.js
- if (reverting.current) {
- reverting.current = false;
- return;
-- }
++function FaceLead({ room: room2, roster, pick: pick2 }) {
++ const lead = room2.face?.lead;
++ const [open, setOpen] = (0, import_react31.useState)(false);
++ const [busy, setBusy] = (0, import_react31.useState)(false);
++ const [error, setError] = (0, import_react31.useState)("");
++ const seats = faceSeats(room2, roster);
++ const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
++ if (!current) return null;
++ const locked = lockedWindow(roster);
++ const canPick = !locked && lead.canPick === true && typeof pick2 === "function";
++ const choose = async (handle) => {
++ setBusy(true);
++ setError("");
++ try {
++ await pick2(handle);
++ setOpen(false);
++ } catch (err) {
++ setError(err?.message || t("Could not change the room lead."));
++ } finally {
++ setBusy(false);
+ }
- if (next === void 0 || next === (value ?? "") || required && next === "") return;
- void onSave(next);
-- };
+ };
- return (0, import_react18.createElement)(multiline ? "textarea" : "input", {
- className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
- value: draft ?? value ?? "",
@@ -19280,24 +19019,7 @@ diff --git a/client.js b/client.js
- }
- if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur();
- }
-+ if (marks.length === 0) return [(0, import_react31.createElement)(PersonMark, { key: "person", size, name: person?.name })];
-+ return marks;
-+}
-+function faceRoomCount(room2, roster) {
-+ const people2 = facePeople(room2, roster).length;
-+ const seats = faceSeats(room2, roster).length;
-+ const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
-+ const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
-+ return `${persons} · ${bots}`;
-+}
-+var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
-+var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
-+function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
-+ const bots = person.seats.flatMap((seat, index) => {
-+ const name = seat.local && onOpenBot ? (0, import_react31.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react31.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
-+ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react31.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
-+ return index === 0 ? marked : [(0, import_react31.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
- });
+- });
-}
-function Row({ label, hint, stack = false, children }) {
- return (0, import_react18.createElement)(
@@ -19315,46 +19037,10 @@ diff --git a/client.js b/client.js
- "button",
- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
-+ return (0, import_react31.createElement)(
-+ "div",
-+ { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
-+ (0, import_react31.createElement)(
-+ "span",
-+ { className: "bt-gs-who bt-face-person" },
-+ (0, import_react31.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
-+ (0, import_react31.createElement)(
-+ "span",
-+ { className: "bt-face-person-text" },
-+ (0, import_react31.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
-+ (0, import_react31.createElement)("span", { className: "bt-face-bots" }, bots)
-+ )
-+ )
- );
- }
+- );
+-}
-function SwitchRow({ label, hint, on, disabled, onChange }) {
- return (0, import_react18.createElement)(
-+function FaceLead({ room: room2, roster, pick: pick2 }) {
-+ const lead = room2.face?.lead;
-+ const [open, setOpen] = (0, import_react31.useState)(false);
-+ const [busy, setBusy] = (0, import_react31.useState)(false);
-+ const [error, setError] = (0, import_react31.useState)("");
-+ const seats = faceSeats(room2, roster);
-+ const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
-+ if (!current) return null;
-+ const locked = lockedWindow(roster);
-+ const canPick = !locked && lead.canPick === true && typeof pick2 === "function";
-+ const choose = async (handle) => {
-+ setBusy(true);
-+ setError("");
-+ try {
-+ await pick2(handle);
-+ setOpen(false);
-+ } catch (err) {
-+ setError(err?.message || t("Could not change the room lead."));
-+ } finally {
-+ setBusy(false);
-+ }
-+ };
+ return (0, import_react31.createElement)(
"div",
- { className: "bt-set-row bt-bs-row" },
@@ -21130,7 +20816,7 @@ diff --git a/client.js b/client.js
)
);
}
--
+
-// src/client/group-settings.js
-var import_react23 = require("react");
-var ROOM_MODES = [
@@ -21141,7 +20827,6 @@ diff --git a/client.js b/client.js
-function roomSubtitle(room2, roster) {
- const admin = roster.byId[room2.admin];
- return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
-+
+// src/client/workflows.js
+var import_react37 = require("react");
+var READ_MS = 8e3;
@@ -21203,7 +20888,24 @@ diff --git a/client.js b/client.js
+ nodes: nodes.filter((node) => node.id !== id),
+ edges: edges.filter((edge) => edge.from !== id && edge.to !== id)
+ };
-+}
+ }
+-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
+- const [adding, setAdding] = (0, import_react23.useState)(false);
+- const [confirming, setConfirming] = (0, import_react23.useState)(false);
+- (0, import_react23.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 4e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id));
+- const admin = roster.byId[room2.admin];
+- const mode = shownMode(room2, roster);
+- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
+- const update = (patch) => run(actions.updateRoom(room2.id, patch));
+- const openBot = (id) => {
+- actions.closeSettings();
+- actions.openSession(id);
+function chainOf(item) {
+ if (typeof item?.chain === "string" && item.chain) return item.chain;
+ if (!Array.isArray(item?.layers) || !Array.isArray(item?.nodes)) return "";
@@ -21267,24 +20969,7 @@ diff --git a/client.js b/client.js
+ level += dir * WIRE_LANE;
+ }
+ return level;
- }
--function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
-- const [adding, setAdding] = (0, import_react23.useState)(false);
-- const [confirming, setConfirming] = (0, import_react23.useState)(false);
-- (0, import_react23.useEffect)(() => {
-- if (!confirming) return void 0;
-- const timer = setTimeout(() => setConfirming(false), 4e3);
-- return () => clearTimeout(timer);
-- }, [confirming]);
-- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
-- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id));
-- const admin = roster.byId[room2.admin];
-- const mode = shownMode(room2, roster);
-- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
-- const update = (patch) => run(actions.updateRoom(room2.id, patch));
-- const openBot = (id) => {
-- actions.closeSettings();
-- actions.openSession(id);
++}
+function rowGap(boxes, box, dir) {
+ const edge = dir < 0 ? box.top : box.bottom;
+ const others = boxes.filter((one) => one.id !== box.id && (dir < 0 ? one.bottom <= box.top + 1 : one.top >= box.bottom - 1));
@@ -23708,7 +23393,7 @@ diff --git a/client.js b/client.js
.bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.bt-about-name{font-size:16px;line-height:22px;font-weight:600}
.bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums}
-@@ -10569,54 +10569,76 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s
+@@ -10569,54 +10569,116 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s
`;
// src/client/settings.js
@@ -23717,8 +23402,8 @@ diff --git a/client.js b/client.js
var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
-var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About DS Bot"]];
-var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
-+var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
-+var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
++var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["plugins", "Plugins"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
++var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
function SetSection({ title, children }) {
- return (0, import_react28.createElement)("section", { className: "bt-set-section" }, (0, import_react28.createElement)("h3", null, title), (0, import_react28.createElement)("div", { className: "bt-set-card" }, children));
+ return (0, import_react42.createElement)("section", { className: "bt-set-section" }, (0, import_react42.createElement)("h3", null, title), (0, import_react42.createElement)("div", { className: "bt-set-card" }, children));
@@ -23732,13 +23417,53 @@ diff --git a/client.js b/client.js
- children ? (0, import_react28.createElement)("div", { className: "bt-set-control" }, children) : null
+ (0, import_react42.createElement)("div", { className: "bt-set-copy" }, (0, import_react42.createElement)("span", null, label), hint ? (0, import_react42.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ children ? (0, import_react42.createElement)("div", { className: "bt-set-control" }, children) : null
- );
- }
++ );
++}
+var NOTIFY_KINDS = [
+ ["approval", "Waiting for approval", "Once more if it still waits after 15 min."],
+ ["finished", "Finished"],
+ ["failed", "Failed"]
+];
++function PluginsPage({ call: call4 }) {
++ const [answer, setAnswer] = (0, import_react42.useState)(null);
++ const [error, setError] = (0, import_react42.useState)("");
++ (0, import_react42.useEffect)(() => {
++ let live = true;
++ call4("plugins/list").then((value) => {
++ if (live) setAnswer(value ?? { plugins: [] });
++ }).catch((failure) => {
++ if (live) setError(failure?.message ?? String(failure));
++ });
++ return () => {
++ live = false;
++ };
++ }, []);
++ const flip = (id, enabled) => {
++ call4("plugins/set", { id, enabled }).then((value) => {
++ setAnswer(value ?? null);
++ setError("");
++ }).catch((failure) => setError(failure?.message ?? String(failure)));
++ };
++ const plugins = answer?.plugins ?? [];
++ return (0, import_react42.createElement)(
++ "div",
++ null,
++ (0, import_react42.createElement)(
++ SetSection,
++ { title: t("Plugins") },
++ error ? (0, import_react42.createElement)("p", { className: "bt-set-hint", role: "alert" }, error) : null,
++ answer === null && !error ? (0, import_react42.createElement)("p", { className: "bt-set-hint" }, t("Loading…")) : null,
++ plugins.map((plugin) => (0, import_react42.createElement)(SwitchRow, {
++ key: plugin.id,
++ label: plugin.name,
++ hint: `${plugin.package} — ${t(plugin.purpose)}`,
++ on: plugin.enabled !== false,
++ onChange: (enabled) => flip(plugin.id, enabled)
++ })),
++ (0, import_react42.createElement)("p", { className: "bt-set-hint" }, answer?.pendingRestart === true ? t("Restart FlyBot to apply the switches you changed.") : t("A switch takes effect the next time FlyBot starts."))
++ )
+ );
+ }
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {};
const themes = allThemes();
@@ -24031,7 +23756,7 @@ diff --git a/client.js b/client.js
const run = (promise) => Promise.resolve(promise).then(() => {
setError("");
return true;
-@@ -10779,104 +10779,258 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
+@@ -10779,104 +10779,259 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
});
const bot = page === "bots" && id ? roster.byId[id] : void 0;
const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
@@ -24124,6 +23849,7 @@ diff --git a/client.js b/client.js
+ shown === "workflows" ? (0, import_react42.createElement)(WorkflowsPage, { call: call2, roster }) : null,
+ pluginPage ? (0, import_react42.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
+ shown === "capabilities" ? (0, import_react42.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
++ shown === "plugins" ? (0, import_react42.createElement)(PluginsPage, { call: call2 }) : null,
+ shown === "schedules" ? (0, import_react42.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
+ shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
+ shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { roster, actions, run, Section: SetSection, Row: SetRow }) : null,
@@ -24361,14 +24087,14 @@ diff --git a/client.js b/client.js
+ [
+ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
+ ],
- [
-- isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
++ [
+ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)]
+ ],
+ pluginItems(structure, bot, actions, roster).map(([label, run]) => [label, LinkIcon, run]),
+ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]]
+ ] : [
-+ [
+ [
+- isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
+ isTile || locked ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
[t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
],
@@ -24509,16 +24235,10 @@ diff --git a/client.js b/client.js
prefs: "ds-bot:prefs",
surface: "ds-bot:surface",
agentSessions: "ds-bot:agent-sessions",
-@@ -11006,276 +11006,40 @@ var migrateBrowsers = (storage) => {
- const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" };
- return [botId, { tabs: [tab], active: tab.id }];
- }));
-- storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
-- for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
-- return;
-- } catch {
-- }
-- }
+@@ -11012,270 +11012,34 @@ var migrateBrowsers = (storage) => {
+ } catch {
+ }
+ }
-};
-function migrateStorage(storage = globalThis.localStorage) {
- for (const key of Object.values(STORAGE)) {
@@ -24780,12 +24500,8 @@ diff --git a/client.js b/client.js
- if (hiddenTurns.includes(entry.turn)) continue;
- if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`);
- if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`);
-+ storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
-+ for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
-+ return;
-+ } catch {
-+ }
-+ }
+- }
+- return lines.join("\n\n");
+};
+function migrateStorage(storage = globalThis.localStorage) {
+ for (const key of Object.values(STORAGE)) {
@@ -24809,8 +24525,7 @@ diff --git a/client.js b/client.js
+ storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] }));
+ }
+ } catch {
- }
-- return lines.join("\n\n");
++ }
}
+
+// src/client/details-drawer.js
@@ -26521,7 +26236,7 @@ diff --git a/client.js b/client.js
.bt-ubody a{color:inherit}
.bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap}
.bt-umention:hover{text-decoration:underline;text-underline-offset:2px}
-@@ -13216,42 +13216,115 @@ var USER_MESSAGE_CSS = `
+@@ -13216,42 +13216,125 @@ var USER_MESSAGE_CSS = `
`;
// src/client/approval-notices.js
@@ -26605,8 +26320,8 @@ diff --git a/client.js b/client.js
+ off();
+ timers.clearTimeout?.(timer);
+ };
-+}
-+
+ }
+
+// src/client/stats-dock.js
+var import_react49 = require("react");
+var STATS_DOCK = "conversation.composer.dock";
@@ -26620,7 +26335,7 @@ diff --git a/client.js b/client.js
+ const { turns, steps } = params;
+ return text2 === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text2;
+ };
- }
++}
+function statsDock(entriesOf, id = STATS_ID) {
+ const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id);
+ function StatsDock(props) {
@@ -26632,7 +26347,7 @@ diff --git a/client.js b/client.js
+ return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} };
+}
+var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) }));
-
++
// src/client/index.js
+var import_react50 = require("react");
var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"];
@@ -26647,6 +26362,16 @@ diff --git a/client.js b/client.js
+ }
ctx.effect(() => installLocale(ctx.get("locale")), "ds-bot: interface copy");
+ ctx.effect(() => installBrandChrome(), "ds-bot: brand chrome");
++ ctx.effect(() => {
++ let live2 = true;
++ call2("plugins/list").then((answer) => {
++ if (live2) setDisabledPlugins((answer?.plugins ?? []).filter((plugin) => plugin.enabled === false).map((plugin) => plugin.id));
++ }).catch(() => {
++ });
++ return () => {
++ live2 = false;
++ };
++ }, "ds-bot: plugin switches");
const roster = createSource(EMPTY_ROSTER);
+ ctx.effect(() => installTabTitle(ctx.sessions.list, roster, typeof document === "undefined" ? void 0 : document), "ds-bot: tab title");
migrateStorage();
diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch
index ecc823bf6..c5d866fec 100644
--- a/patches/ds-bot/0014-flybot-branding.patch
+++ b/patches/ds-bot/0014-flybot-branding.patch
@@ -317,12 +317,12 @@ diff --git a/src/client/settings.js b/src/client/settings.js
// their members, and one opens on its own settings form. Usage widens the dialog.
-const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About DS Bot']]
-const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
-+const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
-+const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
++const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['projects', 'Projects'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['plugins', 'Plugins'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']]
++const PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, plugins: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }
function SetSection({ title, children }) {
return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children))
-@@ -35,6 +35,13 @@ function SetRow({ label, hint, children }) {
+@@ -35,6 +35,48 @@ function SetRow({ label, hint, children }) {
children ? h('div', { className: 'bt-set-control' }, children) : null)
}
@@ -332,6 +332,41 @@ diff --git a/src/client/settings.js b/src/client/settings.js
+ ['finished', 'Finished'],
+ ['failed', 'Failed'],
+]
++// Settings → Plugins (#2564): the four feature plugins, each with one switch. The
++// switch writes FlyBot's state.json; the packages stay installed and the next start
++// reads the answer, so nothing unloads from the session that flipped it.
++function PluginsPage({ call }) {
++ const [answer, setAnswer] = useState(null)
++ const [error, setError] = useState('')
++ useEffect(() => {
++ let live = true
++ call('plugins/list')
++ .then(value => { if (live) setAnswer(value ?? { plugins: [] }) })
++ .catch(failure => { if (live) setError(failure?.message ?? String(failure)) })
++ return () => { live = false }
++ }, [])
++ const flip = (id, enabled) => {
++ call('plugins/set', { id, enabled })
++ .then(value => { setAnswer(value ?? null); setError('') })
++ .catch(failure => setError(failure?.message ?? String(failure)))
++ }
++ const plugins = answer?.plugins ?? []
++ return h('div', null,
++ h(SetSection, { title: t('Plugins') },
++ error ? h('p', { className: 'bt-set-hint', role: 'alert' }, error) : null,
++ answer === null && !error ? h('p', { className: 'bt-set-hint' }, t('Loading…')) : null,
++ plugins.map(plugin => h(SwitchRow, {
++ key: plugin.id,
++ label: plugin.name,
++ hint: `${plugin.package} — ${t(plugin.purpose)}`,
++ on: plugin.enabled !== false,
++ onChange: enabled => flip(plugin.id, enabled),
++ })),
++ h('p', { className: 'bt-set-hint' }, answer?.pendingRestart === true
++ ? t('Restart FlyBot to apply the switches you changed.')
++ : t('A switch takes effect the next time FlyBot starts.'))))
++}
++
+// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393).
function GeneralPage({ roster, actions, run }) {
const prefs = roster.prefs ?? {}
@@ -462,7 +497,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js
const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null
const nav = useGlide(shown, { axis: 'y' })
// A page enters from the right when it is one level in, from the left when it comes
-@@ -178,38 +178,160 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
+@@ -178,38 +178,161 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
// rendered state off screen (content-visibility).
const [usageOpened, setUsageOpened] = useState(shown === 'usage')
if (shown === 'usage' && !usageOpened) setUsageOpened(true)
@@ -512,6 +547,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js
+ shown === 'workflows' ? h(WorkflowsPage, { call, roster }) : null,
+ pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null,
shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null,
++ shown === 'plugins' ? h(PluginsPage, { call }) : null,
shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null,
shown === 'secrets' ? h(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
- shown === 'feedback' ? h(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null,
diff --git a/patches/ds-bot/files/src/client/flybot-registry.js b/patches/ds-bot/files/src/client/flybot-registry.js
index f2e8d7de6..0979586a8 100644
--- a/patches/ds-bot/files/src/client/flybot-registry.js
+++ b/patches/ds-bot/files/src/client/flybot-registry.js
@@ -3,10 +3,25 @@
import { remoteMentionLabel } from '../shared/remote-label.js'
export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']
+// Which feature plugin a slot entry belongs to (#2564), read from the ids the four
+// plugin bundles register under. It lives here because a disabled plugin's bundle still
+// loads — the switch only stops its apply() on the host — so core is what hides the
+// panels it would have drawn, and one list is easier to keep true than five call sites.
+const SLOT_OWNERS = [
+ ['social', /^social(?:$|-)/],
+ ['tree', /^flybot-tree(?:$|-)/],
+ ['timeline', /^timeline$/],
+ ['workflow', /^workflow$/],
+]
+const ownerOf = id => SLOT_OWNERS.find(([, pattern]) => pattern.test(String(id ?? '')))?.[0]
+
export function createRegistry() {
const entries = new Map(SLOTS.map(name => [name, []]))
const listeners = new Set()
let version = 0
+ // Feature plugins the owner switched off (#2564): their entries stay registered but
+ // are never handed out, so every surface that reads a slot drops them together.
+ let disabled = new Set()
const changed = () => {
version += 1
for (const fn of [...listeners]) fn()
@@ -26,7 +41,11 @@ export function createRegistry() {
changed()
}
},
- entries: name => entries.get(name) ?? [],
+ entries: name => (entries.get(name) ?? []).filter(entry => !disabled.has(ownerOf(entry.id))),
+ setDisabledPlugins(ids = []) {
+ disabled = new Set([...ids])
+ changed()
+ },
subscribe(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
@@ -41,6 +60,13 @@ export function registry() {
return root.__flybot__.slots
}
+// The live registry's switches, set from the host's answer at start and from Settings.
+export function setDisabledPlugins(ids = []) {
+ registry().setDisabledPlugins(ids)
+}
+
+export const pluginOfSlot = ownerOf
+
// Extra "@" rows from plugins (remote Bots and the like), after the team's own Bots.
// Extra rows in the sidebar '+' new-chat menu (after New Bot / New group chat).
export function newChatActionRows(context = {}) {
diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js
index 92f448ca0..8a1b886b8 100644
--- a/patches/ds-bot/files/src/host/hooks.js
+++ b/patches/ds-bot/files/src/host/hooks.js
@@ -23,6 +23,7 @@ import { FEED_EVENTS, installFeed } from './flybot-feed.js'
import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { retiredBot } from './store.js'
+import { pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from './plugins.js'
import { codePoints } from './text.js'
import { cut } from '../shared/text-boundary.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'
@@ -1102,6 +1103,17 @@ export function install(rt) {
},
}
const isReadEndpoint = name => READ_ENDPOINTS.has(name) || readHandlers.has(name)
+
+ // The owner's plugin switches (#2564). A flip only writes state.json: the plugins
+ // already loaded keep running until the next start, and `pendingRestart` says so.
+ api.handle('plugins/list', async () => ({ plugins: pluginSwitches(state), pendingRestart: rt.pluginSwitchPending === true }), { read: true })
+ api.handle('plugins/set', async (body = {}) => {
+ const before = pluginSwitches(state).find(plugin => plugin.id === body.id)
+ const plugins = setPluginSwitch(state, body.id, body.enabled)
+ if (before !== undefined && before.enabled !== body.enabled) rt.pluginSwitchPending = true
+ await rt.save()
+ return { plugins, pendingRestart: rt.pluginSwitchPending === true }
+ })
async function endpoint(name, payload, { actor } = {}) {
await rt.load()
const body = payload ?? {}
@@ -1143,6 +1155,10 @@ export function install(rt) {
const fields = fieldsFor(plugin)
return {
version: FLYBOT_API_VERSION,
+ // The owner's switch for a feature plugin (#2564): a plugin asks once, in its own
+ // apply(), and registers nothing when this answers false. Read from the state file
+ // the last run wrote, because plugins load before the team is loaded.
+ pluginEnabled: id => pluginEnabledAtStart(rt.statePath, id),
plugin,
scope: build,
bot: {
diff --git a/patches/ds-bot/files/src/host/plugins.js b/patches/ds-bot/files/src/host/plugins.js
new file mode 100644
index 000000000..7d2a249d4
--- /dev/null
+++ b/patches/ds-bot/files/src/host/plugins.js
@@ -0,0 +1,51 @@
+// The four FlyBot feature plugins and the owner's switch for each (#2564).
+//
+// The switch does not install or uninstall anything: the packages stay in the DSH
+// profile as they are, and the answer lives in FlyBot's own state.json. A plugin reads
+// it once, in its own `apply()` at start, and returns before registering anything when
+// it is off — tools, slots, endpoints and prompt text all come from that call, so they
+// disappear together and come back together. That is why a flip applies at the next
+// start: nothing unloads from a running session.
+import { readFileSync } from 'node:fs'
+
+export const FEATURE_PLUGINS = Object.freeze([
+ { id: 'social', name: 'Bot Social', package: '@flybot/social', purpose: "Chat with a friend's Bots on a Bot Social server." },
+ { id: 'tree', name: 'Tree', package: '@flybot/tree', purpose: 'Run Bots as a tree under a coordinator.' },
+ { id: 'timeline', name: 'Timeline', package: '@flybot/timeline', purpose: 'Read the team activity as a timeline.' },
+ { id: 'workflow', name: 'Workflow', package: '@flybot/workflow', purpose: 'Run workflow graphs across several Bots.' },
+])
+
+export const FEATURE_PLUGIN_IDS = Object.freeze(FEATURE_PLUGINS.map(plugin => plugin.id))
+
+const known = id => FEATURE_PLUGIN_IDS.includes(id)
+
+// The four switches as the Settings page shows them: on unless state.json says off.
+export function pluginSwitches(state) {
+ const saved = state?.flybot?.plugins ?? {}
+ return FEATURE_PLUGINS.map(plugin => ({ ...plugin, enabled: saved[plugin.id]?.enabled !== false }))
+}
+
+// Writes one switch into the state the next save persists. Only these four ids are
+// accepted, so a typo cannot grow a fifth switch nobody reads.
+export function setPluginSwitch(state, id, enabled) {
+ const name = String(id ?? '')
+ if (!known(name)) throw new Error(`flybot: unknown plugin ${JSON.stringify(name)}`)
+ if (typeof enabled !== 'boolean') throw new Error('flybot: a plugin switch takes true or false')
+ state.flybot ??= {}
+ state.flybot.plugins ??= {}
+ state.flybot.plugins[name] = { enabled }
+ return pluginSwitches(state)
+}
+
+// Read at the plugin's own apply(), which runs before the host has loaded the team:
+// the file the last run wrote is the answer, and a missing or unreadable file means
+// "on", the same as a fresh install. An id this list does not know is left alone, so a
+// plugin added later without a switch here keeps loading.
+export function pluginEnabledAtStart(statePath, id) {
+ if (!known(id)) return true
+ try {
+ return JSON.parse(readFileSync(statePath, 'utf8'))?.flybot?.plugins?.[id]?.enabled !== false
+ } catch {
+ return true
+ }
+}
diff --git a/patches/ds-bot/files/test/plugin-switches.test.js b/patches/ds-bot/files/test/plugin-switches.test.js
new file mode 100644
index 000000000..25e4be14d
--- /dev/null
+++ b/patches/ds-bot/files/test/plugin-switches.test.js
@@ -0,0 +1,109 @@
+// #2564: the owner's switch for the four feature plugins. The switch lives in FlyBot's
+// own state.json, is read at the plugin's apply() on start, and hides that plugin's slot
+// entries in the interface.
+import assert from 'node:assert/strict'
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { createRuntime } from '../index.js'
+import { FEATURE_PLUGIN_IDS, pluginEnabledAtStart, pluginSwitches, setPluginSwitch } from '../src/host/plugins.js'
+import { createRegistry, pluginOfSlot, registry, setDisabledPlugins } from '../src/client/flybot-registry.js'
+import { fakeContext } from './fake-ctx.js'
+
+const home = () => mkdtempSync(join(tmpdir(), 'dsb-plugins-'))
+const runtime = (state) => {
+ const dir = home()
+ if (state !== undefined) writeFileSync(join(dir, 'state.json'), JSON.stringify(state))
+ const ctx = fakeContext()
+ const rt = createRuntime(ctx, { home: dir, diagnosticsFile: false })
+ return { dir, rt, ctx }
+}
+
+test('every switch is on until state.json says otherwise', () => {
+ assert.deepEqual(FEATURE_PLUGIN_IDS, ['social', 'tree', 'timeline', 'workflow'])
+ assert.deepEqual(pluginSwitches({}).map(plugin => plugin.enabled), [true, true, true, true])
+ assert.deepEqual(pluginSwitches({ flybot: { plugins: { workflow: { enabled: false } } } }).map(plugin => [plugin.id, plugin.enabled]), [
+ ['social', true], ['tree', true], ['timeline', true], ['workflow', false],
+ ])
+})
+
+test('a switch only takes the four known ids, and true/false only', () => {
+ const state = {}
+ assert.equal(setPluginSwitch(state, 'social', false).find(plugin => plugin.id === 'social').enabled, false)
+ assert.deepEqual(state.flybot.plugins.social, { enabled: false })
+ assert.throws(() => setPluginSwitch(state, 'core', false), /unknown plugin "core"/)
+ assert.throws(() => setPluginSwitch(state, 'tree', 'off'), /takes true or false/)
+})
+
+test('the host reads the switch from the file the last run wrote', () => {
+ const dir = home()
+ try {
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), true, 'no file means on')
+ writeFileSync(join(dir, 'state.json'), JSON.stringify({ flybot: { plugins: { social: { enabled: false } } } }))
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), false)
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'tree'), true)
+ writeFileSync(join(dir, 'state.json'), '{ not json')
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'social'), true, 'an unreadable file means on')
+ assert.equal(pluginEnabledAtStart(join(dir, 'state.json'), 'not-a-plugin'), true, 'an unknown id keeps loading')
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+test('plugins/list answers all four switches and plugins/set persists a flip', async () => {
+ const { dir, rt } = runtime()
+ try {
+ const listed = await rt.flybotEndpoint('plugins/list')
+ assert.equal(listed.pendingRestart, false)
+ assert.deepEqual(listed.plugins.map(plugin => plugin.id), [...FEATURE_PLUGIN_IDS])
+ assert.equal(listed.plugins.find(plugin => plugin.id === 'workflow').package, '@flybot/workflow')
+
+ const saved = await rt.flybotEndpoint('plugins/set', { id: 'workflow', enabled: false })
+ assert.equal(saved.pendingRestart, true)
+ assert.equal(saved.plugins.find(plugin => plugin.id === 'workflow').enabled, false)
+ await rt.save()
+ const onDisk = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8'))
+ assert.deepEqual(onDisk.flybot.plugins.workflow, { enabled: false })
+ // The running process keeps what it loaded; only the next start reads this.
+ assert.equal(rt.flybot.pluginEnabled('workflow'), false)
+ assert.equal(rt.flybot.pluginEnabled('social'), true)
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+test('a disabled plugin keeps no slot entry visible, and the other plugins stay', () => {
+ const slots = createRegistry()
+ slots.register('flybot.sidebar.section', { id: 'social' })
+ slots.register('flybot.sidebar.section', { id: 'workflow' })
+ slots.register('flybot.sidebar.section', { id: 'flybot-tree' })
+ slots.register('flybot.settings.page', { id: 'timeline' })
+ slots.register('flybot.sidebar.section', { id: 'core-thing' })
+ assert.deepEqual(slots.entries('flybot.sidebar.section').map(entry => entry.id), ['social', 'workflow', 'flybot-tree', 'core-thing'])
+
+ slots.setDisabledPlugins(['social', 'tree'])
+ assert.deepEqual(slots.entries('flybot.sidebar.section').map(entry => entry.id), ['workflow', 'core-thing'])
+ assert.deepEqual(slots.entries('flybot.settings.page').map(entry => entry.id), ['timeline'])
+ slots.setDisabledPlugins([])
+ assert.equal(slots.entries('flybot.sidebar.section').length, 4)
+})
+
+test('every id the four plugin bundles register maps to its own plugin', () => {
+ // The ids the plugin client bundles use today; a new one here has to map too, which is
+ // what keeps the filter honest when a plugin grows a panel.
+ const known = {
+ social: ['social', 'social-approval', 'social-face', 'social-group-strip', 'social-handle', 'social-held-reply', 'social-invite', 'social-paused', 'social-room-end', 'social-room-invite', 'social-thinking'],
+ tree: ['flybot-tree', 'flybot-tree-archived', 'flybot-tree-reorg'],
+ timeline: ['timeline'],
+ workflow: ['workflow'],
+ }
+ for (const [id, ids] of Object.entries(known)) for (const slot of ids) assert.equal(pluginOfSlot(slot), id, slot)
+ assert.equal(pluginOfSlot('core-thing'), undefined)
+})
+
+test('the live registry is the one Settings flips', () => {
+ setDisabledPlugins(['workflow'])
+ assert.equal(registry().entries('flybot.sidebar.section').length, 0)
+ setDisabledPlugins([])
+})
diff --git a/patches/ds-bot/files/test/settings-page-slot.test.js b/patches/ds-bot/files/test/settings-page-slot.test.js
index 73fff6f8b..f37182949 100644
--- a/patches/ds-bot/files/test/settings-page-slot.test.js
+++ b/patches/ds-bot/files/test/settings-page-slot.test.js
@@ -56,6 +56,6 @@ test('a page whose plugin is gone falls back to General, and without plugins the
const nodes = all(tree)
assert.equal(text(nodes.find(node => node.type === 'h2')), 'General')
const items = nodes.filter(node => node.type === 'button' && node.props.className === 'bt-settings-item').map(text)
- assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Scheduled tasks'])
+ assert.deepEqual(items.slice(0, 7), ['General', 'Bots', 'Group chats', 'Projects', 'Connectors', 'Capabilities', 'Plugins'])
assert.equal(nodes.some(node => node.props?.entry !== undefined), false)
})
diff --git a/plugins/social/index.js b/plugins/social/index.js
index 7bcbae7f7..2b425b19e 100644
--- a/plugins/social/index.js
+++ b/plugins/social/index.js
@@ -22,6 +22,10 @@ export const name = 'flybot-social'
export const inject = ['flybot']
export function apply(ctx, config = {}) {
+
+ // Owner switch (#2564): off means this process registers none of the plugin's tools,
+ // slots or endpoints. Read from FlyBot's state.json at start, so it applies next launch.
+ if (ctx.flybot?.pluginEnabled?.('social') === false) return
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh')
const base = { inboxPollMs: 15_000, facePollMs: 4_000, credentialsPath: join(dshHome, 'bot', 'flybot-social.json'), ...config }
const flybot = typeof ctx.flybot.scope === 'function' ? ctx.flybot.scope(NS) : ctx.flybot
diff --git a/plugins/timeline/index.js b/plugins/timeline/index.js
index a303bd658..b8818ed12 100644
--- a/plugins/timeline/index.js
+++ b/plugins/timeline/index.js
@@ -89,6 +89,10 @@ function makeExporter(config, dir, warn) {
}
export function apply(ctx, config = {}) {
+
+ // Owner switch (#2564): off means this process registers none of the plugin's tools,
+ // slots or endpoints. Read at start, so it applies next launch.
+ if (ctx.flybot?.pluginEnabled?.('timeline') === false) return { timeline: undefined, enabled: false, dispose() {} }
if (config?.enabled !== true) return { timeline: undefined, enabled: false, dispose() {} }
const adapter = createAdapter(ctx.flybot)
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh')
diff --git a/plugins/tree/index.js b/plugins/tree/index.js
index e7f11f7a7..5c910eda3 100644
--- a/plugins/tree/index.js
+++ b/plugins/tree/index.js
@@ -11,6 +11,10 @@ export const name = 'flybot-tree'
export const inject = ['flybot']
export function apply(ctx, config = {}) {
+
+ // Owner switch (#2564): off means this process registers none of the plugin's tools,
+ // slots, prompt text or middleware. Read at start, so it applies next launch.
+ if (ctx.flybot?.pluginEnabled?.('tree') === false) return { tree: undefined, dispose() {} }
const fb = createAdapter(ctx.flybot)
// Cordis gives ctx.logger as a factory; a host may give a ready logger object instead.
const logger = typeof ctx.logger === 'function' ? ctx.logger('flybot-tree') : ctx.logger
diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js
index 98f2ee91f..78325029d 100644
--- a/plugins/workflow/index.js
+++ b/plugins/workflow/index.js
@@ -18,6 +18,10 @@ export const name = 'flybot-workflow'
export const inject = ['flybot']
export function apply(ctx, config = {}) {
+
+ // Owner switch (#2564): off means this process registers none of the plugin's tools,
+ // slots or endpoints. Read at start, so it applies next launch.
+ if (ctx.flybot?.pluginEnabled?.('workflow') === false) return { enabled: false, dispose() {} }
if (config?.enabled === false) return { enabled: false, dispose() {} }
if (!ctx?.flybot || typeof ctx.flybot.scope !== 'function') {
throw new Error('@flybot/workflow needs ctx.flybot.scope (flybot API v1)')