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)')