From 9e893bde8c9c72b781d7395894a09018c1a3ea58 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:54:46 +0800 Subject: [PATCH 1/3] Draw no write control on seven Settings pages in a read-only window The host refuses set-prefs, set-main, update-bot, update-room, the connector writes, the capability writes and the secret writes with bot/read-only, so General, the Bots page's Main Bot and animation sections, a Bot's settings, a group's settings, Connectors, Capabilities and Secrets drew controls whose only answer could be that refusal. Each page now reads the same lockedWindow(roster) the other surfaces use and keeps its facts as plain text. Fixes #2474 --- .../2474-settings-pages-readonly.fixed.md | 1 + core/client.js | 166 + core/src/client/bot-settings.js | 20 +- core/src/client/capability-settings.js | 15 + core/src/client/connectors.js | 12 + core/src/client/group-settings.js | 24 + core/src/client/i18n.js | 2 + core/src/client/secret-card.js | 17 + core/src/client/settings.js | 25 + core/test/settings-readonly-pages.test.js | 233 + docs/subsystems/core-client.md | 1 + patches/ds-bot/0001-core-fixes.patch | 39 +- patches/ds-bot/0003-flybot-api-client.patch | 56 +- patches/ds-bot/0008-client-bundle.patch | 7485 +++++++++-------- patches/ds-bot/0012-room-membership.patch | 31 +- patches/ds-bot/0014-flybot-branding.patch | 72 +- .../test/settings-readonly-pages.test.js | 233 + patches/ds-bot/topics.json | 1 + 18 files changed, 4927 insertions(+), 3506 deletions(-) create mode 100644 changelog.d/2474-settings-pages-readonly.fixed.md create mode 100644 core/test/settings-readonly-pages.test.js create mode 100644 patches/ds-bot/files/test/settings-readonly-pages.test.js diff --git a/changelog.d/2474-settings-pages-readonly.fixed.md b/changelog.d/2474-settings-pages-readonly.fixed.md new file mode 100644 index 000000000..7b68655fc --- /dev/null +++ b/changelog.d/2474-settings-pages-readonly.fixed.md @@ -0,0 +1 @@ +A read-only window no longer draws the write controls on the seven Settings pages that still had them: **General** (theme, accent and the notification switches), **Bots** (both Main Bot choices and the animation picker), a Bot's own settings, a group chat's settings, **Connectors**, **Capabilities** and **Secrets**. Each page now states its facts as plain text — the theme and accent in use, who holds the Main Bot role, the animation setting, a Bot's role, model, folder and instructions, a group's members and notice, each connector's state, each capability and each stored key with who may use it — because the host refuses every one of those writes with `bot/read-only`. **New Bot** stays as an entry, since the form behind it already explains the lock, and a writable window is unchanged. ([#2474](https://github.com/ThinkFlowLab/FlyBot/issues/2474)) diff --git a/core/client.js b/core/client.js index 87ca71285..5d01aeaeb 100644 --- a/core/client.js +++ b/core/client.js @@ -694,6 +694,8 @@ var ZH = { "Unread marks, badges, and the brand mark.": "未读标记、徽标和品牌标志。", "Theme accent": "主题强调色", "Custom accent": "自定义强调色", + On: "开", + None: "无", "Bots · {count}": "Bot · {count}", "First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.": "排在侧栏最上面。选另一个 Bot 就会把主 Bot 交给它,{name} 随之不再是主 Bot。", "Choose a Bot…": "选择一个 Bot…", @@ -6682,6 +6684,33 @@ function SecretsPage({ roster, actions, run, Section }) { const [open, setOpen] = (0, import_react9.useState)(null); const [adding, setAdding] = (0, import_react9.useState)(false); const secrets = roster.secrets ?? []; + if (lockedWindow(roster)) { + return [ + (0, import_react9.createElement)( + "p", + { key: "about", className: "bt-secret-about" }, + t("Keys your Bots asked for. Bots never see the values: they use them as "), + (0, import_react9.createElement)("code", null, "$DSH_SECRET_NAME"), + t(" in their shell, and every value is replaced by "), + (0, import_react9.createElement)("code", null, "[secret:NAME]"), + t(" before anything goes to a model.") + ), + (0, import_react9.createElement)( + Section, + { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, + secrets.length === 0 ? (0, import_react9.createElement)("div", { className: "bt-set-row" }, (0, import_react9.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : secrets.map((secret) => (0, import_react9.createElement)( + "div", + { key: secret.name, className: "bt-set-row" }, + (0, import_react9.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react9.createElement)("span", { className: "bt-secret-title" }, (0, import_react9.createElement)(KeyIcon), (0, import_react9.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react9.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), + (0, import_react9.createElement)("span", { className: "bt-set-hint" }, scopeLabel(secret.scope, roster)) + ) + )) + ) + ]; + } return [ (0, import_react9.createElement)( "p", @@ -14113,6 +14142,27 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; + if (lockedWindow(roster)) { + return (0, import_react27.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react27.createElement)("h3", null, t("Shared capabilities")), + (0, import_react27.createElement)( + "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)( + "div", + { key: item.name, className: "bt-set-row" }, + (0, import_react27.createElement)( + "span", + { 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"))) + ) + )) + ) + ); + } return (0, import_react27.createElement)( "section", { className: "bt-set-section" }, @@ -14865,6 +14915,43 @@ function BotSettingsForm({ bot, roster, actions, run }) { const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; + if (lockedWindow(roster)) { + return (0, import_react30.createElement)( + "div", + { className: "bt-bs" }, + (0, import_react30.createElement)( + "div", + { className: "bt-bs-hero" }, + (0, import_react30.createElement)(BotMark, { bot, size: 64, live: false }), + (0, import_react30.createElement)( + "div", + { className: "bt-bs-who" }, + (0, import_react30.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) + ) + ), + (0, import_react30.createElement)( + Card, + { title: t("Profile") }, + (0, import_react30.createElement)(Row, { label: t("Label"), hint: bot.role || t("Bot") }) + ), + (0, import_react30.createElement)( + Card, + { title: t("Model") }, + (0, import_react30.createElement)(Row, { label: t("Model"), hint: modelOf(bot, roster)?.name ?? modelHint(bot, roster) }) + ), + (0, import_react30.createElement)( + Card, + { title: t("Working folder") }, + (0, import_react30.createElement)(Row, { label: t("Folder"), hint: bot.workDir || t("Not set") }) + ), + (0, import_react30.createElement)( + Card, + { title: t("Instructions") }, + (0, import_react30.createElement)(Row, { label: t("What this Bot does"), hint: bot.instructions || t("None"), stack: true }) + ) + ); + } const save = (patch) => run(actions.updateBot(bot.id, patch)); const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); const remove = () => { @@ -15190,6 +15277,37 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { const admin = roster.byId[room2.admin]; const mode = shownMode(room2, roster); const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; + if (lockedWindow(roster)) { + return (0, import_react31.createElement)( + "div", + { className: "bt-bs" }, + (0, import_react31.createElement)( + "div", + { className: "bt-bs-hero" }, + (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), + (0, import_react31.createElement)( + "div", + { className: "bt-bs-who" }, + (0, import_react31.createElement)("span", { className: "bt-bs-name" }, room2.name), + (0, import_react31.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) + ) + ), + (0, import_react31.createElement)( + Card, + { title: t("Members · {count}", { count: face ? people2.length : members.length + humans.length + remotes.length }) }, + face ? people2.map((person) => (0, import_react31.createElement)(Row, { key: person.key, label: facePersonLabel(person), hint: t("Human") })) : [ + humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: person.displayName || person.id, hint: t("Human") })), + members.map((member) => (0, import_react31.createElement)(Row, { + key: member.id, + label: member.name, + hint: [room2.admin === member.id ? t("Admin") : "", room2.taskLead === member.id ? t("Task lead") : ""].filter(Boolean).join(" · ") || t("Bot") + })), + remotes.map((person) => (0, import_react31.createElement)(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") })) + ] + ), + room2.notice ? (0, import_react31.createElement)(Card, { title: t("Notice") }, (0, import_react31.createElement)(Row, { label: t("Group notice"), hint: room2.notice, stack: true })) : null + ); + } const update = (patch) => run(actions.updateRoom(room2.id, patch)); const openBot = (id) => { actions.closeSettings(); @@ -18743,6 +18861,22 @@ function ConnectorRow({ connector, roster, actions }) { } function ConnectorsPage({ roster, actions }) { const list = roster?.connectors ?? []; + if (lockedWindow(roster)) { + return (0, import_react39.createElement)( + "section", + { className: "bt-set-section" }, + (0, import_react39.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react39.createElement)("div", { className: "bt-set-row" }, (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react39.createElement)( + "div", + { key: connector.id, className: "bt-set-row" }, + (0, import_react39.createElement)( + "span", + { className: "bt-set-copy" }, + (0, import_react39.createElement)("span", { className: "bt-bs-label" }, connector.name), + (0, import_react39.createElement)("span", { className: "bt-set-hint" }, statusText2(connector)) + ) + ))) + ); + } return (0, import_react39.createElement)( "section", { className: "bt-set-section" }, @@ -19131,6 +19265,23 @@ function GeneralPage({ roster, actions, run }) { clearTimeout(timer.current); timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350); }; + if (lockedWindow(roster)) { + const accent = ACCENT_INKS.find((ink) => INKS[ink][0] === prefs.accent); + return [ + (0, import_react42.createElement)( + SetSection, + { key: "look", title: t("Appearance") }, + (0, import_react42.createElement)(SetRow, { label: t("Theme"), hint: t(themes[current].label) }), + (0, import_react42.createElement)(SetRow, { label: t("Accent"), hint: accent ? t(capitalize2(accent)) : prefs.accent ?? t("Default") }) + ), + (0, import_react42.createElement)( + SetSection, + { key: "notify", title: t("Notifications") }, + (0, import_react42.createElement)(SetRow, { label: t("In other chats"), hint: prefs.notifications !== false ? t("On") : t("Off") }), + NOTIFY_KINDS.map(([kind, label]) => (0, import_react42.createElement)(SetRow, { key: kind, label: t(label), hint: prefs.notifications !== false && prefs.notify?.[kind] !== false ? t("On") : t("Off") })) + ) + ]; + } return [ (0, import_react42.createElement)( SetSection, @@ -19195,6 +19346,14 @@ function MainBotsSection({ roster, actions, run }) { const [first, ...others] = mains; const own = roster.bots.filter((bot) => !bot.remote); const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); + if (lockedWindow(roster)) { + return (0, import_react42.createElement)( + SetSection, + { title: t("Main Bot") }, + (0, import_react42.createElement)(SetRow, { label: t("Main Bot"), hint: first ? first.name : void 0 }), + others.map((bot) => (0, import_react42.createElement)(SetRow, { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") })) + ); + } return (0, import_react42.createElement)( SetSection, { title: t("Main Bot") }, @@ -19234,6 +19393,13 @@ function MainBotsSection({ roster, actions, run }) { var MOTIONS = [["quiet", "Quiet"], ["normal", "Normal"], ["lively", "Lively"]]; function MotionSection({ roster, actions, run }) { const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal"; + if (lockedWindow(roster)) { + return (0, import_react42.createElement)( + SetSection, + { title: t("Animation") }, + (0, import_react42.createElement)(SetRow, { label: t("Bot animation"), hint: t(MOTIONS.find(([id]) => id === motion)?.[1] ?? "Normal") }) + ); + } return (0, import_react42.createElement)( SetSection, { title: t("Animation") }, diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index 087c15126..ceb38159c 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -8,7 +8,7 @@ import { lookOf, BotMark } from './mark.js' import { ModelMenu, modelOf } from './model-menu.js' import { ModelTestRow, MODEL_TEST_CSS } from './model-test.js' import { FolderMenu } from './folder-menu.js' -import { BotSchedules } from './schedules.js' +import { BotSchedules, lockedWindow } from './schedules.js' import { BotCapabilitiesCard } from './capability-settings.js' import { BotConnectorsCard } from './connector-bots.js' import { memoryCountText, useMemoryCount } from './memory-panel.js' @@ -208,6 +208,24 @@ export function BotSettingsForm({ bot, roster, actions, run }) { const isMainBot = isMainOf(roster, bot.id) const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 const isTile = roster.mainBotId === bot.id + // The host refuses every write of this form in a read-only window, so the page keeps the + // Bot's own facts as text and draws no control (#2474). + if (lockedWindow(roster)) { + return h('div', { className: 'bt-bs' }, + h('div', { className: 'bt-bs-hero' }, + h(BotMark, { bot, size: 64, live: false }), + h('div', { className: 'bt-bs-who' }, + h('span', { className: 'bt-bs-name' }, bot.name), + h('span', { className: 'bt-bs-sub' }, [bot.role, isMainBot ? t('Main Bot') : ''].filter(Boolean).join(' · ') || t('Bot')))), + h(Card, { title: t('Profile') }, + h(Row, { label: t('Label'), hint: bot.role || t('Bot') })), + h(Card, { title: t('Model') }, + h(Row, { label: t('Model'), hint: modelOf(bot, roster)?.name ?? modelHint(bot, roster) })), + h(Card, { title: t('Working folder') }, + h(Row, { label: t('Folder'), hint: bot.workDir || t('Not set') })), + h(Card, { title: t('Instructions') }, + h(Row, { label: t('What this Bot does'), hint: bot.instructions || t('None'), stack: true }))) + } const save = patch => run(actions.updateBot(bot.id, patch)) const duplicate = () => run(actions.duplicateBot(bot.id).then(copy => actions.openSession(copy.id))) const remove = () => { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true) } diff --git a/core/src/client/capability-settings.js b/core/src/client/capability-settings.js index d572cd916..857cd1390 100644 --- a/core/src/client/capability-settings.js +++ b/core/src/client/capability-settings.js @@ -1,4 +1,5 @@ import { createElement as h, useEffect, useState } from 'react' +import { lockedWindow } from './schedules.js' import { t } from './i18n.js' const blank = () => ({ name: '', kind: 'mcp', description: '', transport: 'stdio', command: '', argsText: '', envText: '{}', url: '', headersText: '{}', content: '', items: [] }) @@ -133,6 +134,20 @@ export function CapabilitiesPage({ roster, actions, run }) { setConfirming('') void run(actions.deleteCapability(item.name)) } + // The host refuses saving, deleting and approving a capability in a read-only window, so + // the page lists what it holds as text (#2474). + if (lockedWindow(roster)) { + return h('section', { className: 'bt-set-section' }, + h('h3', null, t('Shared capabilities')), + h('div', { className: 'bt-set-card bt-connectors-card' }, + items.length === 0 ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No capabilities saved'))) + : items.map(item => h('div', { key: item.name, className: 'bt-set-row' }, + h('span', { className: 'bt-set-copy' }, + h('span', { className: 'bt-bs-label' }, item.name), + h('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')))))))) + } return h('section', { className: 'bt-set-section' }, h('h3', null, t('Shared capabilities')), h('div', { className: 'bt-actions' }, diff --git a/core/src/client/connectors.js b/core/src/client/connectors.js index f660199cc..b9cf2a304 100644 --- a/core/src/client/connectors.js +++ b/core/src/client/connectors.js @@ -3,6 +3,7 @@ import { useMorph } from './motion.js' import { MaskedInput } from './secret-card.js' import { ConnectorIcon } from './icons.js' import { SwitchRow } from './bot-settings.js' +import { lockedWindow } from './schedules.js' import { ConnectorBots, usageText } from './connector-bots.js' import { t } from './i18n.js' import { copiedToClipboard } from '../shared/remote-label.js' @@ -277,6 +278,17 @@ function ConnectorRow({ connector, roster, actions }) { // Settings → Connectors: one row per connector. export function ConnectorsPage({ roster, actions }) { const list = roster?.connectors ?? [] + // Every connect, disconnect and send is refused in a read-only window, so the page says + // what each connector is doing instead of drawing its row (#2474). + if (lockedWindow(roster)) { + return h('section', { className: 'bt-set-section' }, + h('div', { className: 'bt-set-card bt-connectors-card' }, list.length === 0 + ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No connectors'))) + : list.map(connector => h('div', { key: connector.id, className: 'bt-set-row' }, + h('span', { className: 'bt-set-copy' }, + h('span', { className: 'bt-bs-label' }, connector.name), + h('span', { className: 'bt-set-hint' }, statusText(connector))))))) + } return h('section', { className: 'bt-set-section' }, h('div', { className: 'bt-set-card bt-connectors-card' }, list.length === 0 ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No connectors'))) diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 4f7716eda..d11ba52db 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -5,6 +5,7 @@ import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookAvoidingLoca import { setLocalBots } from './characters.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' +import { lockedWindow } from './schedules.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' import { StandingApprovalsCard } from './standing-approvals.js' @@ -181,6 +182,29 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' + // Every control here writes `update-room` or `delete-room`, which the host refuses in a + // read-only window; the form then keeps the group's facts as text (#2474). + if (lockedWindow(roster)) { + return h('div', { className: 'bt-bs' }, + h('div', { className: 'bt-bs-hero' }, + h(RoomAvatar, { room, roster, size: 64 }), + h('div', { className: 'bt-bs-who' }, + h('span', { className: 'bt-bs-name' }, room.name), + h('span', { className: 'bt-bs-sub' }, roomSubtitle(room, roster)))), + h(Card, { title: t('Members · {count}', { count: face ? people.length : members.length + humans.length + remotes.length }) }, + face + ? people.map(person => h(Row, { key: person.key, label: facePersonLabel(person), hint: t('Human') })) + : [ + humans.map(person => h(Row, { key: person.id, label: person.displayName || person.id, hint: t('Human') })), + members.map(member => h(Row, { + key: member.id, + label: member.name, + hint: [room.admin === member.id ? t('Admin') : '', room.taskLead === member.id ? t('Task lead') : ''].filter(Boolean).join(' · ') || t('Bot'), + })), + remotes.map(person => h(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") })), + ]), + room.notice ? h(Card, { title: t('Notice') }, h(Row, { label: t('Group notice'), hint: room.notice, stack: true })) : null) + } const update = patch => run(actions.updateRoom(room.id, patch)) const openBot = (id) => { actions.closeSettings(); actions.openSession(id) } const remove = () => { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index f695425a9..d9bd9f3bf 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -635,6 +635,8 @@ const ZH = { 'Unread marks, badges, and the brand mark.': '未读标记、徽标和品牌标志。', 'Theme accent': '主题强调色', 'Custom accent': '自定义强调色', + On: '开', + None: '无', 'Bots · {count}': 'Bot · {count}', 'First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.': '排在侧栏最上面。选另一个 Bot 就会把主 Bot 交给它,{name} 随之不再是主 Bot。', 'Choose a Bot…': '选择一个 Bot…', diff --git a/core/src/client/secret-card.js b/core/src/client/secret-card.js index a428c180c..73165abb0 100644 --- a/core/src/client/secret-card.js +++ b/core/src/client/secret-card.js @@ -7,6 +7,7 @@ import { CheckIcon, CloseIcon, TrashIcon } from './icons.js' import { BotAvatar, BotMark } from './mark.js' import { contentText, currentPart, historyLoading } from './text.js' import { dateLocale, t } from './i18n.js' +import { lockedWindow } from './schedules.js' import { SPRING_POP, SPRING_SHAPE, spring } from './motion.js' const MIN_VALUE = 4 @@ -260,6 +261,22 @@ export function SecretsPage({ roster, actions, run, Section }) { const [open, setOpen] = useState(null) const [adding, setAdding] = useState(false) const secrets = roster.secrets ?? [] + // The host refuses saving, replacing and deleting a key in a read-only window, so the + // page lists the stored names and who may use them as text (#2474). + if (lockedWindow(roster)) { + return [ + h('p', { key: 'about', className: 'bt-secret-about' }, + t('Keys your Bots asked for. Bots never see the values: they use them as '), h('code', null, '$DSH_SECRET_NAME'), + t(' in their shell, and every value is replaced by '), h('code', null, '[secret:NAME]'), t(' before anything goes to a model.')), + h(Section, { key: 'list', title: t('Secrets · {count}', { count: secrets.length }) }, + secrets.length === 0 + ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No keys yet. When a Bot needs one, it shows a card in its chat.'))) + : secrets.map(secret => h('div', { key: secret.name, className: 'bt-set-row' }, + h('span', { className: 'bt-set-copy' }, + h('span', { className: 'bt-secret-title' }, h(KeyIcon), h('code', { className: 'bt-secret-name' }, secret.name), secret.purpose ? h('span', { className: 'bt-secret-purpose' }, secret.purpose) : null), + h('span', { className: 'bt-set-hint' }, scopeLabel(secret.scope, roster)))))), + ] + } return [ h('p', { key: 'about', className: 'bt-secret-about' }, t('Keys your Bots asked for. Bots never see the values: they use them as '), h('code', null, '$DSH_SECRET_NAME'), diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 0d6ddd727..3fcceffe5 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -58,6 +58,19 @@ function GeneralPage({ roster, actions, run }) { clearTimeout(timer.current) timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350) } + // The host refuses `set-prefs` in a read-only window, so the page says what is chosen + // instead of drawing the controls that would ask for it (#2474). + if (lockedWindow(roster)) { + const accent = ACCENT_INKS.find(ink => INKS[ink][0] === prefs.accent) + return [ + h(SetSection, { key: 'look', title: t('Appearance') }, + h(SetRow, { label: t('Theme'), hint: t(themes[current].label) }), + h(SetRow, { label: t('Accent'), hint: accent ? t(capitalize(accent)) : prefs.accent ?? t('Default') })), + h(SetSection, { key: 'notify', title: t('Notifications') }, + h(SetRow, { label: t('In other chats'), hint: prefs.notifications !== false ? t('On') : t('Off') }), + NOTIFY_KINDS.map(([kind, label]) => h(SetRow, { key: kind, label: t(label), hint: prefs.notifications !== false && prefs.notify?.[kind] !== false ? t('On') : t('Off') }))), + ] + } return [ h(SetSection, { key: 'look', title: t('Appearance') }, h(SetRow, { label: t('Theme'), hint: t('Colors for the whole team, on every device.') }, @@ -94,6 +107,13 @@ function MainBotsSection({ roster, actions, run }) { // Only the user's own Bots can hold the role; a friend's Bot is never offered (#276). const own = roster.bots.filter(bot => !bot.remote) const candidates = own.filter(bot => !isMainOf(roster, bot.id)) + // Every control here writes `set-main`, which the host refuses in a read-only window; the + // section then names the Bots that hold the role (#2474). + if (lockedWindow(roster)) { + return h(SetSection, { title: t('Main Bot') }, + h(SetRow, { label: t('Main Bot'), hint: first ? first.name : undefined }), + others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }))) + } return h(SetSection, { title: t('Main Bot') }, h(SetRow, { label: t('Main Bot'), @@ -116,6 +136,11 @@ function MainBotsSection({ roster, actions, run }) { const MOTIONS = [['quiet', 'Quiet'], ['normal', 'Normal'], ['lively', 'Lively']] function MotionSection({ roster, actions, run }) { const motion = roster.prefs?.motion === 'quiet' || roster.prefs?.motion === 'lively' ? roster.prefs.motion : 'normal' + // The three steps write `set-prefs`; a read-only window names the one in use (#2474). + if (lockedWindow(roster)) { + return h(SetSection, { title: t('Animation') }, + h(SetRow, { label: t('Bot animation'), hint: t(MOTIONS.find(([id]) => id === motion)?.[1] ?? 'Normal') })) + } return h(SetSection, { title: t('Animation') }, h(SetRow, { label: t('Bot animation') }, h(Segmented, { label: t('Bot animation'), value: motion, className: 'bt-motion-seg' }, MOTIONS.map(([id, label]) => h('button', { diff --git a/core/test/settings-readonly-pages.test.js b/core/test/settings-readonly-pages.test.js new file mode 100644 index 000000000..efa6b0408 --- /dev/null +++ b/core/test/settings-readonly-pages.test.js @@ -0,0 +1,233 @@ +// #2474: in a window that holds the team read-only the host refuses every write with +// `bot/read-only`, but seven Settings pages still drew their controls: General's theme +// select and switches, the Bots page's Main Bot and animation controls, and the live forms +// of a Bot, a group chat, Connectors, Capabilities and Secrets. Each page now reads the +// roster's own read-only sentence through `lockedWindow` and draws a plain-text view +// instead: no control anywhere, and the facts of the page kept as text. A writable window +// is untouched, and the standing sentence stays in the dialog header — once per page. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { SettingsDialog } from './src/client/settings.js'", + "export { InlineText, SwitchRow } from './src/client/bot-settings.js'", + "export { Segmented } from './src/client/motion.js'", + "export { ModelMenu } from './src/client/model-menu.js'", + "export { FolderMenu } from './src/client/folder-menu.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useId: () => 'id', + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, createElement: () => ({ getContext: () => ({}) }) }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, InlineText, SwitchRow, Segmented, ModelMenu, FolderMenu } = module.exports + +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' + +const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } +const writer = { id: 'writer', name: 'Writer', role: 'Editor', model: 'mock/m1', workDir: null, instructions: 'Write the copy.' } +const editor = { id: 'editor', name: 'Editor', role: '', model: 'mock/m1', workDir: null, instructions: '' } +const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [] } +const github = { id: 'github', name: 'GitHub', kind: 'tool', state: 'ready', account: 'xuke' } +const browser = { name: 'browser', kind: 'mcp', transport: 'stdio', description: 'Open pages' } +const token = { name: 'GITHUB_TOKEN', purpose: 'Push branches', scope: 'all', createdAt: 0 } +const baseRoster = { + bots: [chief, writer, editor], + byId: { chief, writer, editor }, + rooms: [room], + roomsById: { r1: room }, + mainBotIds: ['chief', 'writer'], + mainBotId: 'chief', + prefs: { theme: 'deepseek', motion: 'quiet', notifications: true }, + models: [{ id: 'm1', name: 'Mock One', served: true }], + connectors: [github], + capabilities: [browser], + secrets: [token], + humans: [], + remoteBots: [], + memory: true, +} +const lockedRoster = { ...baseRoster, readOnly: SENTENCE } + +// Every action that asks the host to change something. A locked page may draw an entry that +// navigates (New Bot, a Bot's row, Open board); it may not draw one that lands here. +const MUTATING = ['updateBot', 'deleteBot', 'createBot', 'duplicateBot', 'setMain', 'setPrefs', 'setFlags', 'openMemory', 'refreshModels', + 'updateRoom', 'deleteRoom', 'createRoom', 'saveCapability', 'deleteCapability', 'approveCapability', 'setBotCapability', + 'connectorConnect', 'connectorDisconnect', 'connectorRetry', 'connectorSend', 'connectorAllow', 'connectorBlock', 'connectorApps', + 'secretSet', 'secretUpdate', 'secretDelete', 'secretCancel', 'secretAllow', 'scheduleCreate', 'scheduleUpdate', 'scheduleDelete'] +const makeActions = (calls) => new Proxy({ openSettings() {}, closeSettings() {}, openSession() {}, notify() {}, showDetails() {} }, { + get: (target, name) => (MUTATING.includes(name) + ? () => { calls.push(String(name)) } + : target[name]), +}) + +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} + +// The widgets that render a control inside themselves: drawn is enough, they never run here. +const WIDGETS = new Set([InlineText, SwitchRow, Segmented, ModelMenu, FolderMenu]) +const nameOf = (node) => (typeof node.type === 'string' ? node.type : node.type?.name) +const WRITE_WIDGET_NAMES = new Set(['ConnectorRow', 'SecretRow', 'CapabilityRow', 'CapabilityForm', 'ScopeEditor', 'ValueEditor', + 'AddSecret', 'StandingApprovalsCard', 'BotCapabilitiesCard', 'BotConnectorsCard', 'BotSchedules', 'FlyBotDetails', + 'FlyBotRoomMembers', 'NewGroupForm', 'AllowedSenders']) + +const isWidget = (node) => WIDGETS.has(node.type) || WRITE_WIDGET_NAMES.has(nameOf(node)) + +// A page is drawn by the dialog one level down, so mounting it means calling it with the +// props the dialog handed it; child components are called too, one at a time, because the +// controls a locked window must not draw sit inside them. +const expand = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) expand(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 8) { + if (isWidget(node)) { out.push(node); expand(node.props?.children, out, depth + 1); return out } + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } + return expand(rendered, out, depth + 1) + } + out.push(node) + expand(node.props?.children, out, depth) + return out +} + +const mount = (roster, key, id, name, calls = []) => { + const actions = makeActions(calls) + slots = [] + effects = [] + index = 0 + const tree = SettingsDialog({ page: key, id, roster, actions, useUpdate: () => ({}) }) + const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === name) + assert.ok(node, `the dialog draws ${name}`) + slots = [] + effects = [] + index = 0 + const nodes = expand(node.type(node.props)) + return { tree, nodes, actions, calls } +} + +// A control is a field, a switch, or a widget that draws one. +const isControl = (node) => ['select', 'input', 'textarea'].includes(nameOf(node)) + || node.props?.role === 'switch' + || isWidget(node) + +const PAGES = [ + { key: 'general', id: null, name: 'GeneralPage', facts: ['DeepSeek', 'On'] }, + { key: 'bots', id: null, name: 'BotsPage', facts: ['Main Bot', 'Chief', 'Writer', 'Bot animation', 'Quiet'] }, + { key: 'bots', id: 'chief', name: 'BotSettingsForm', facts: ['Chief', 'Researcher', 'Run the team.'] }, + { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Read this first.'] }, + { key: 'connectors', id: null, name: 'ConnectorsPage', facts: ['GitHub', 'xuke'] }, + { key: 'capabilities', id: null, name: 'CapabilitiesPage', facts: ['browser', 'Open pages'] }, + { key: 'secrets', id: null, name: 'SecretsPage', facts: ['GITHUB_TOKEN', 'Push branches'] }, +] + +// Pressing what a locked page draws may navigate; it may not write. +const pressure = (nodes) => nodes.filter(node => nameOf(node) === 'button') + +test('a read-only window draws no write control on any of the seven Settings pages (#2474)', () => { + const controls = [] + const writes = [] + const missing = [] + for (const page of PAGES) { + const { nodes, calls } = mount(lockedRoster, page.key, page.id, page.name) + for (const control of nodes.filter(isControl)) controls.push(`${page.name}: ${nameOf(control)}`) + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point, not its answer */ } } + for (const call of calls) writes.push(`${page.name}: ${call}`) + const facts = text(nodes) + for (const fact of page.facts) if (!facts.includes(fact)) missing.push(`${page.name}: "${fact}"`) + } + assert.deepEqual(controls, [], 'a read-only window draws no control') + assert.deepEqual(writes, [], 'no control a read-only window draws reaches a write') + assert.deepEqual(missing, [], 'each page keeps its facts as text') +}) + +test('a read-only Bots page draws no Main Bot or animation control, and sends neither write (#2474)', () => { + // Product's probe on P-571 saw both defects from outside its own copy: both windows drew + // the same controls, and a press in the locked one still sent set-main and set-prefs. + const { nodes, calls } = mount(lockedRoster, 'bots', null, 'BotsPage') + assert.equal(nodes.filter(node => node.type === 'select').length, 0, 'no Main Bot picker') + assert.equal(nodes.filter(node => node.type === Segmented).length, 0, 'no animation picker') + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point */ } } + assert.deepEqual(calls, [], 'the page sends no set-main and no set-prefs') + assert.ok(nodes.some(node => nameOf(node) === 'button' && /New Bot/.test(text(node))), 'the New Bot entry stays; its form is locked since #2465') + assert.ok(text(nodes).includes('Chief'), 'the Main Bot in use is still named') +}) + +test('the standing sentence stays in the dialog header, once per page (#2474)', () => { + for (const page of PAGES) { + const tree = SettingsDialog({ page: page.key, id: page.id, roster: lockedRoster, actions: makeActions([]), useUpdate: () => ({}) }) + const hints = all(tree).filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes('bt-read-only')) + assert.equal(hints.length, 1, `one standing sentence on ${page.name}`) + assert.ok(text(hints[0]).includes(SENTENCE), 'the header keeps the host\'s own sentence') + } +}) + +test('a writable window keeps every page as it is today (#2474)', () => { + const counts = (nodes) => nodes.filter(isControl).length + const general = mount(baseRoster, 'general', null, 'GeneralPage').nodes + assert.equal(general.filter(node => node.type === 'select').length, 1, 'General keeps its theme select') + assert.equal(general.filter(node => node.type === 'input').length, 1, 'General keeps its colour field') + assert.equal(counts(general), 6, 'General keeps its four switches beside them') + + const bots = mount(baseRoster, 'bots', null, 'BotsPage').nodes + assert.equal(bots.filter(node => node.type === 'select').length, 2, 'Bots keeps both of its Main Bot selects') + assert.equal(bots.filter(node => node.type === Segmented).length, 1, 'Bots keeps its animation picker') + + assert.ok(counts(mount(baseRoster, 'bots', 'chief', 'BotSettingsForm').nodes) >= 3, 'a Bot keeps its live form') + assert.ok(counts(mount(baseRoster, 'groups', 'r1', 'GroupSettingsForm').nodes) >= 3, 'a group keeps its live form') + assert.ok(mount(baseRoster, 'connectors', null, 'ConnectorsPage').nodes.some(node => nameOf(node) === 'ConnectorRow'), 'Connectors keeps its live rows') + assert.ok(mount(baseRoster, 'capabilities', null, 'CapabilitiesPage').nodes.some(node => nameOf(node) === 'CapabilityRow'), 'Capabilities keeps its rows') + assert.ok(mount(baseRoster, 'secrets', null, 'SecretsPage').nodes.some(node => nameOf(node) === 'SecretRow'), 'Secrets keeps its rows') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index ec234494d..490b213ad 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -132,6 +132,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (`lockedWindow`, `schedules.js`) and, in a read-only window, draws the host's sentence in the form's place instead of mounting `BotSettingsForm` or `GroupSettingsForm`, so no write control the host would refuse is drawn for a Bot or a group, and a writable window keeps the form unchanged (#2387). A refused write in that view follows the same rule as the dialog (#2385): the `bt-error` line is a sibling above `bt-drawer-body bt-set-body`, not its first child, so a press at the bottom of the group page — Pin, Hide, Make admin, Remove — writes the host's refusal where the press happened instead of at the top of the box it was scrolled past. The drawer's overview view holds the same two rules: its refusal line is a sibling above `bt-drawer-body`, and in a read-only window the Bot's name is plain text with no model, permission or working-folder control beside it, above a `role="status"` line carrying the host's own sentence, so the overview offers only what the host will not refuse. Since P-547 those three cards keep their titles and their saved values as plain text as well (`ReadOnlyOverview`: the model's name and provider, the permission preset's label, the working folder's last segment with the full path as its `title`), because the window refuses the writes, not the reading — a value that vanishes with its control leaves the page saying less than the host knows; a writable window keeps every control and draws none of those values twice. Tests: `core/test/details-overview-readonly.test.js`. A group's overview follows it (#2391 left it to P-544): the name is plain text with no rename control, `Remove my Bot` is gone, and the host's one sentence sits above the reads — a shared room's approval card keeps its headline and rows and drops its decisions — while a writable window keeps every control. Tests: `core/test/details-bot-settings-readonly.test.js`, `core/test/details-drawer-refusal-line.test.js`, `core/test/details-overview-readonly.test.js`, `core/test/details-group-overview-readonly.test.js`. The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Settings → **Projects** (`ProjectsPage` and `ProjectsList` in `settings.js`) draws the write half of the Project record: a name field, **New Project**, a **Bind to {name}** choice on every Project row and **Unbind** on every binding. In a window that holds the team read-only (`lockedWindow`, `schedules.js`) none of them is drawn, because the host refuses `project-create` and `project-bind`; **Open board**, the Project rows with their names and working folders, and the members already bound stay readable, and a writable window is unchanged. Tests: `core/test/project-settings-page.test.js`. +- The rest of Settings follows that lock (#2474): `GeneralPage`, `MainBotsSection` and `MotionSection` in `settings.js`, `BotSettingsForm` (`bot-settings.js`), `GroupSettingsForm` (`group-settings.js`), `ConnectorsPage` (`connectors.js`), `CapabilitiesPage` (`capability-settings.js`) and `SecretsPage` (`secret-card.js`) each read the same `lockedWindow(roster)` and, in a read-only window, draw no `select`, `input`, switch or component that would render one — every one of them asked for `set-prefs`, `set-main`, `update-bot`, `update-room`, a `connector-*` write, `save-capability`/`delete-capability` or a `secret-*` write, which the host refuses with `bot/read-only`. The facts stay as text: the theme and the accent in use, the notification state, the Main Bots and the animation setting, a Bot's role, model, working folder and instructions, a group's members, admin and notice, each connector's own state sentence, each capability's description and each stored key's name with who may use it. Entries that only navigate stay — **New Bot**, whose form explains the lock itself since #2465, and a row that opens a Bot's or a group's page — and the standing sentence is still the dialog header's, drawn once above the page and never again inside it. A writable window is unchanged. Tests: `core/test/settings-readonly-pages.test.js`. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). - The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone. - What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws `.utilities` — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]` and the face-room `@` filter. `installHeaderEffects` (`src/client/header-effects.js`) is installed once from `apply` and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while `beginVisit`/`endVisit` keyed on more than the Session would open a second visit for a chat that was only just opened. `test/header-effects.test.js` drives those transitions on jsdom; `test/header-slot.test.js` pins that no such effect runs in `HeaderPill` and that FlyBot claims no other header seat. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 305678883..ffa14083d 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -229,13 +229,14 @@ diff --git a/src/client/browser-pane.js b/src/client/browser-pane.js diff --git a/src/client/connectors.js b/src/client/connectors.js --- a/src/client/connectors.js +++ b/src/client/connectors.js -@@ -1,41 +1,229 @@ +@@ -1,41 +1,230 @@ -import { createElement as h, useRef, useState } from 'react' +import { createElement as h, useCallback, useEffect, useRef, useState } from 'react' import { useMorph } from './motion.js' import { MaskedInput } from './secret-card.js' +import { ConnectorIcon } from './icons.js' +import { SwitchRow } from './bot-settings.js' ++import { lockedWindow } from './schedules.js' +import { ConnectorBots, usageText } from './connector-bots.js' import { t } from './i18n.js' +import { copiedToClipboard } from '../shared/remote-label.js' @@ -466,7 +467,7 @@ diff --git a/src/client/connectors.js b/src/client/connectors.js return h('div', { ref: row, className: 'bt-connector', 'data-state': connector.state }, h('div', { className: 'bt-connector-head' }, h('span', { className: 'bt-connector-mark' }, Mark ? h(Mark) : null), -@@ -44,15 +44,45 @@ function ConnectorRow({ connector, actions }) { +@@ -44,23 +44,64 @@ function ConnectorRow({ connector, actions }) { h('span', { className: 'bt-connector-status' }, statusText(connector))), h('span', { className: 'bt-connector-actions' }, connector.state === 'error' ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t('Try again')) : null, @@ -498,7 +499,10 @@ diff --git a/src/client/connectors.js b/src/client/connectors.js + })) + : h('label', { className: 'bt-q-field bt-secret-field' }, h(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t('Composio API key') : t('GitHub token'), autoFocus: true })), + gateway ? h(AppPicker, { apps, onChange: setApps, search: token.trim() === '' ? undefined : search }) : null, -+ h('div', { className: 'bt-connector-form-row' }, + h('div', { className: 'bt-connector-form-row' }, +- h('a', { className: 'bt-connector-link', href: TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, t('Create a token on GitHub')), +- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setForm(false); setToken(''); setError(null) } }, t('Cancel')), +- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || token.trim() === '', onClick: connect }, busy ? t('Connecting…') : t('Connect')))) : null, + h('a', { className: 'bt-connector-link', href: channel ? (chosen?.link ?? FEISHU_WEBHOOK_URL) : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, + channel ? t(chosen?.linkLabel ?? 'Get a webhook URL in Feishu') : gateway ? t('Get a Composio API key') : t('Create a token on GitHub')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setForm(false); setToken(''); setValues({}); setError(null) } }, t('Cancel')), @@ -506,17 +510,27 @@ diff --git a/src/client/connectors.js b/src/client/connectors.js + on && gateway && picking ? h('div', { className: 'bt-connector-form' }, + h(AppPicker, { apps, onChange: setApps, search, statuses }), + h('span', { className: 'bt-set-hint' }, t('The first time a Bot uses an app, it shows a sign-in link in the chat.')), - h('div', { className: 'bt-connector-form-row' }, -- h('a', { className: 'bt-connector-link', href: TOKEN_URL, target: '_blank', rel: 'noopener noreferrer' }, t('Create a token on GitHub')), -- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setForm(false); setToken(''); setError(null) } }, t('Cancel')), -- h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || token.trim() === '', onClick: connect }, busy ? t('Connecting…') : t('Connect')))) : null, ++ h('div', { className: 'bt-connector-form-row' }, + h('span', { className: 'bt-connector-link' }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setPicking(false); setError(null) } }, t('Cancel')), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t('Connecting…') : t('Save')))) : null, error ? h('div', { className: 'bt-connector-error', role: 'alert' }, error) : null) } -@@ -62,5 +62,5 @@ export function ConnectorsPage({ roster, actions }) { + // Settings → Connectors: one row per connector. + export function ConnectorsPage({ roster, actions }) { + const list = roster?.connectors ?? [] ++ // Every connect, disconnect and send is refused in a read-only window, so the page says ++ // what each connector is doing instead of drawing its row (#2474). ++ if (lockedWindow(roster)) { ++ return h('section', { className: 'bt-set-section' }, ++ h('div', { className: 'bt-set-card bt-connectors-card' }, list.length === 0 ++ ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No connectors'))) ++ : list.map(connector => h('div', { key: connector.id, className: 'bt-set-row' }, ++ h('span', { className: 'bt-set-copy' }, ++ h('span', { className: 'bt-bs-label' }, connector.name), ++ h('span', { className: 'bt-set-hint' }, statusText(connector))))))) ++ } return h('section', { className: 'bt-set-section' }, h('div', { className: 'bt-set-card bt-connectors-card' }, list.length === 0 ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No connectors'))) @@ -1125,6 +1139,15 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Shapes Whale: '鲸鱼', Star: '星星', Moon: '月亮', Wave: '声波', Squircle: '圆角方', Bull: '牛角', Blossom: '花朵', +@@ -301,6 +301,8 @@ const ZH = { + 'Unread marks, badges, and the brand mark.': '未读标记、徽标和品牌标志。', + 'Theme accent': '主题强调色', + 'Custom accent': '自定义强调色', ++ On: '开', ++ None: '无', + 'Bots · {count}': 'Bot · {count}', + 'First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.': '排在侧栏最上面。选另一个 Bot 就会把主 Bot 交给它,{name} 随之不再是主 Bot。', + 'Choose a Bot…': '选择一个 Bot…', @@ -312,9 +312,10 @@ const ZH = { 'No group chats yet.': '还没有群聊。', 'A group chat lets several Bots work on one thing with you.': '在群聊里,几个 Bot 和你一起做一件事。', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e4b396f34..2fba57cc2 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -371,7 +371,8 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js import { ModelMenu, modelOf } from './model-menu.js' +import { ModelTestRow, MODEL_TEST_CSS } from './model-test.js' import { FolderMenu } from './folder-menu.js' - import { BotSchedules } from './schedules.js' +-import { BotSchedules } from './schedules.js' ++import { BotSchedules, lockedWindow } from './schedules.js' import { BotCapabilitiesCard } from './capability-settings.js' -import { useMemoryCount } from './memory-panel.js' +import { BotConnectorsCard } from './connector-bots.js' @@ -406,7 +407,7 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js return h(Card, { title: t('Memory') }, h('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) }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, label)), -@@ -199,6 +199,7 @@ export function BotSettingsForm({ bot, roster, actions, run }) { +@@ -199,9 +199,28 @@ export function BotSettingsForm({ bot, roster, actions, run }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -414,6 +415,27 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js const isMainBot = isMainOf(roster, bot.id) const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 const isTile = roster.mainBotId === bot.id ++ // The host refuses every write of this form in a read-only window, so the page keeps the ++ // Bot's own facts as text and draws no control (#2474). ++ if (lockedWindow(roster)) { ++ return h('div', { className: 'bt-bs' }, ++ h('div', { className: 'bt-bs-hero' }, ++ h(BotMark, { bot, size: 64, live: false }), ++ h('div', { className: 'bt-bs-who' }, ++ h('span', { className: 'bt-bs-name' }, bot.name), ++ h('span', { className: 'bt-bs-sub' }, [bot.role, isMainBot ? t('Main Bot') : ''].filter(Boolean).join(' · ') || t('Bot')))), ++ h(Card, { title: t('Profile') }, ++ h(Row, { label: t('Label'), hint: bot.role || t('Bot') })), ++ h(Card, { title: t('Model') }, ++ h(Row, { label: t('Model'), hint: modelOf(bot, roster)?.name ?? modelHint(bot, roster) })), ++ h(Card, { title: t('Working folder') }, ++ h(Row, { label: t('Folder'), hint: bot.workDir || t('Not set') })), ++ h(Card, { title: t('Instructions') }, ++ h(Row, { label: t('What this Bot does'), hint: bot.instructions || t('None'), stack: true }))) ++ } + const save = patch => run(actions.updateBot(bot.id, patch)) + const duplicate = () => run(actions.duplicateBot(bot.id).then(copy => actions.openSession(copy.id))) + const remove = () => { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true) } @@ -222,8 +222,9 @@ export function BotSettingsForm({ bot, roster, actions, run }) { h(Row, { label: t('Label'), hint: t('Optional. Shown under the name.') }, h(InlineText, { label: t('Label'), value: bot.role, placeholder: t('Research, admin…'), maxLength: 24, onSave: role => save({ role }) }))), h(Card, { title: t('Model') }, @@ -487,6 +509,36 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} ` +diff --git a/src/client/capability-settings.js b/src/client/capability-settings.js +--- a/src/client/capability-settings.js ++++ b/src/client/capability-settings.js +@@ -1,4 +1,5 @@ + import { createElement as h, useEffect, useState } from 'react' ++import { lockedWindow } from './schedules.js' + import { t } from './i18n.js' + + const blank = () => ({ name: '', kind: 'mcp', description: '', transport: 'stdio', command: '', argsText: '', envText: '{}', url: '', headersText: '{}', content: '', items: [] }) +@@ -133,6 +133,20 @@ export function CapabilitiesPage({ roster, actions, run }) { + setConfirming('') + void run(actions.deleteCapability(item.name)) + } ++ // The host refuses saving, deleting and approving a capability in a read-only window, so ++ // the page lists what it holds as text (#2474). ++ if (lockedWindow(roster)) { ++ return h('section', { className: 'bt-set-section' }, ++ h('h3', null, t('Shared capabilities')), ++ h('div', { className: 'bt-set-card bt-connectors-card' }, ++ items.length === 0 ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No capabilities saved'))) ++ : items.map(item => h('div', { key: item.name, className: 'bt-set-row' }, ++ h('span', { className: 'bt-set-copy' }, ++ h('span', { className: 'bt-bs-label' }, item.name), ++ h('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')))))))) ++ } + return h('section', { className: 'bt-set-section' }, + h('h3', null, t('Shared capabilities')), + h('div', { className: 'bt-actions' }, diff --git a/src/client/cells.js b/src/client/cells.js --- a/src/client/cells.js +++ b/src/client/cells.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 819004f53..167cbbc72 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -462,6 +462,15 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", +@@ -363,6 +363,8 @@ var ZH = { + "Unread marks, badges, and the brand mark.": "未读标记、徽标和品牌标志。", + "Theme accent": "主题强调色", + "Custom accent": "自定义强调色", ++ On: "开", ++ None: "无", + "Bots · {count}": "Bot · {count}", + "First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.": "排在侧栏最上面。选另一个 Bot 就会把主 Bot 交给它,{name} 随之不再是主 Bot。", + "Choose a Bot…": "选择一个 Bot…", @@ -374,9 +374,10 @@ var ZH = { "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", @@ -6339,7 +6348,7 @@ diff --git a/client.js b/client.js @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,6487 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2833 +2676,6514 @@ 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} } @@ -7200,6 +7209,33 @@ diff --git a/client.js b/client.js + const [open, setOpen] = (0, import_react9.useState)(null); + const [adding, setAdding] = (0, import_react9.useState)(false); + const secrets = roster.secrets ?? []; ++ if (lockedWindow(roster)) { ++ return [ ++ (0, import_react9.createElement)( ++ "p", ++ { key: "about", className: "bt-secret-about" }, ++ t("Keys your Bots asked for. Bots never see the values: they use them as "), ++ (0, import_react9.createElement)("code", null, "$DSH_SECRET_NAME"), ++ t(" in their shell, and every value is replaced by "), ++ (0, import_react9.createElement)("code", null, "[secret:NAME]"), ++ t(" before anything goes to a model.") ++ ), ++ (0, import_react9.createElement)( ++ Section, ++ { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, ++ secrets.length === 0 ? (0, import_react9.createElement)("div", { className: "bt-set-row" }, (0, import_react9.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : secrets.map((secret) => (0, import_react9.createElement)( ++ "div", ++ { key: secret.name, className: "bt-set-row" }, ++ (0, import_react9.createElement)( ++ "span", ++ { className: "bt-set-copy" }, ++ (0, import_react9.createElement)("span", { className: "bt-secret-title" }, (0, import_react9.createElement)(KeyIcon), (0, import_react9.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react9.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), ++ (0, import_react9.createElement)("span", { className: "bt-set-hint" }, scopeLabel(secret.scope, roster)) ++ ) ++ )) ++ ) ++ ]; ++ } + return [ + (0, import_react9.createElement)( + "p", @@ -8262,7 +8298,15 @@ 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); +} @@ -8295,7 +8339,11 @@ 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" }; @@ -8322,23 +8370,89 @@ 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 workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); } --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 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 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 useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react11.useEffect)(() => { @@ -8364,12 +8478,54 @@ 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 () => { + return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); + stop = true; + clearTimeout(timer); -+ }; + }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- 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 }) { +- 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" }) + }, [done, sessionId, callId]); + let replies = live.replies; + if (done) { @@ -8463,8 +8619,10 @@ 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 AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +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) { @@ -8479,7 +8637,13 @@ diff --git a/client.js b/client.js + }; + visit(root); + return calls; -+} + } +-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 useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react11.useLayoutEffect)(() => { @@ -8488,7 +8652,16 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, 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 renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -8499,7 +8672,35 @@ diff --git a/client.js b/client.js + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react11.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react11.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +-} +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +-} +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; + case "stop_bot": { + const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react11.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); @@ -8533,11 +8734,7 @@ diff --git a/client.js b/client.js + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); + return (0, import_react11.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); - } -- }; -- try { -- visit(sheet.cssRules); -- } catch { ++ } + case "ask_user": { + const question = roster.questions?.[sessionId]; + if (!question || question.callId !== call4.callId) return (0, import_react11.createElement)("span", { className: "bt-q-gone", hidden: true }); @@ -8569,39 +8766,90 @@ diff --git a/client.js b/client.js + default: + return null; } -- return names; +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; } --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); -+function ToolCell({ node, sessionId, useRoster, useUi, 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] ?? botByName(roster.bots, nameOrId); -+ const calls = hidden ? [] : callTree(root); -+ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); -+ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.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((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); -+ if (drawn.length === 0) return null; -+ 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/awaiting.js -+var AWAIT_REPLY_MS = 10 * 60 * 1e3; -+function awaitingReply(roster, sessionId, now = Date.now()) { -+ const at = roster?.remoteAwaiting?.[sessionId]; -+ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; -+ return now - at < AWAIT_REPLY_MS; -+} +-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 ToolCell({ node, sessionId, useRoster, useUi, 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] ?? botByName(roster.bots, nameOrId); ++ const calls = hidden ? [] : callTree(root); ++ const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); ++ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.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((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); ++ if (drawn.length === 0) return null; ++ 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" })); ++// src/client/awaiting.js ++var AWAIT_REPLY_MS = 10 * 60 * 1e3; ++function awaitingReply(roster, sessionId, now = Date.now()) { ++ const at = roster?.remoteAwaiting?.[sessionId]; ++ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; ++ return now - at < AWAIT_REPLY_MS; + } +-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" }) +- ); + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; @@ -8609,7 +8857,13 @@ diff --git a/client.js b/client.js + if (!remote) return null; + 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]"; @@ -8619,7 +8873,13 @@ diff --git a/client.js b/client.js + 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"); @@ -8631,7 +8891,9 @@ diff --git a/client.js b/client.js + 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]; @@ -8643,7 +8905,15 @@ diff --git a/client.js b/client.js + 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]"); @@ -8657,7 +8927,79 @@ diff --git a/client.js b/client.js + 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"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +- ); +-} +-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"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-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); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react12.useRef)(-1); + const wasSending = (0, import_react12.useRef)(false); @@ -8669,9 +9011,7 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; - } -- return compiled.get(fragment); -- }); ++ } + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); @@ -8690,23 +9030,50 @@ diff --git a/client.js b/client.js + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); -+ }; + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + }, [remote, sending]); + return replied; } --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); +-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; +- } +- 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(); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react12.useState)(null); + (0, import_react12.useEffect)(() => { @@ -8714,27 +9081,79 @@ diff --git a/client.js b/client.js + setHost(null); + return void 0; } -- 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(); -- }; +- }) : 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 +- ); + let frame = 0; + const find = () => { + frame = 0; @@ -8753,15 +9172,37 @@ diff --git a/client.js b/client.js + }, [active, where]); + return host; } -- --// 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; +-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 useRowEntrances(replacing) { + const check = (0, import_react12.useRef)(replacing); + check.current = replacing; @@ -8769,22 +9210,14 @@ diff --git a/client.js b/client.js + const seen = /* @__PURE__ */ new WeakSet(); + const started = Date.now(); + let column = null; - let frame = 0; -- let point = null; -- const place = () => { ++ 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 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)); ++ frame = 0; + const next = document.querySelector("[data-chat-flow]"); + if (next !== column) { + observer.disconnect(); @@ -8806,10 +9239,7 @@ diff --git a/client.js b/client.js + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); + } - }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -8817,13 +9247,22 @@ diff --git a/client.js b/client.js + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); ++ }; + }, []); -+} + } +-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); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -8862,7 +9301,23 @@ diff --git a/client.js b/client.js + if (wait <= 0) { + make(); + return void 0; -+ } + } +- pending.answer({ answers: next }); +- }; +- 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)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -8883,9 +9338,7 @@ diff --git a/client.js b/client.js + shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; + setPhase((current) => current === "out" ? null : current); + }, 140); - }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); ++ }; + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); + return () => clearTimeout(timer); @@ -8921,7 +9374,17 @@ 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)( @@ -8935,7 +9398,19 @@ diff --git a/client.js b/client.js + ) + ) + ), 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; +- } +- 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"]); @@ -8945,59 +9420,94 @@ diff --git a/client.js b/client.js + 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/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.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 }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- 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/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 () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -+ escapeLayers.splice(escapeLayers.indexOf(ref), 1); -+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); - }; -- }, [enabled]); -+ }, []); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- 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, + }; +-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 }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", - "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) } })); +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +- }); ++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 })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +- ); +function useDialogFocus(ref) { + (0, import_react13.useEffect)(() => { + const before = document.activeElement; @@ -9010,11 +9520,18 @@ diff --git a/client.js b/client.js + }; + }, []); } --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 SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- 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 usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280); @@ -9024,8 +9541,64 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } --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 SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (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 of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- 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")) +- ) +- ); + +// src/client/member-status.js +var import_react14 = require("react"); @@ -9049,50 +9622,45 @@ diff --git a/client.js b/client.js + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); } --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 SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- 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 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 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; +-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 }); +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 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 SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- 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 botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); @@ -9100,16 +9668,16 @@ diff --git a/client.js b/client.js + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); } --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 SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- 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 moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); @@ -9120,58 +9688,70 @@ diff --git a/client.js b/client.js + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; } --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)( +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- 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", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { 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")) +- ) +function Face({ entry, size }) { + return (0, import_react14.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)); -- }) ++ "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 ?? "") }) ); } -- --// 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 ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +- } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "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")) +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)}`; @@ -9182,17 +9762,8 @@ diff --git a/client.js b/client.js + (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 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 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 SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -9206,15 +9777,20 @@ diff --git a/client.js b/client.js + waiting ? (0, import_react14.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react14.createElement)(Shimmer, null, doing) : null -+ ), + ), +- 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 + elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } --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 SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- 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 MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -9241,7 +9817,24 @@ diff --git a/client.js b/client.js + const rows = groups.slice(0, ROWS_MAX); + const more = groups.length - rows.length; + return (0, import_react14.createElement)( -+ "div", + "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( +- "span", +- { 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 }))) +- ) +- ), +- open ? (0, import_react5.createElement)( + { ref, className: "bt-mw-dock-stack" }, + rows.map((item) => (0, import_react14.createElement)(WorkRow, { + key: item.room.id, @@ -9254,18 +9847,37 @@ diff --git a/client.js b/client.js + onOpen: () => actions.openSession(item.room.id) + })), + more > 0 ? (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 })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (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")) +- ) + { className: "bt-mw-row bt-mw-line bt-mw-static" }, + (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) -+ ) : null + ) : null ); } --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 AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); +- } +- }); +function useFlowColumn(ref, active, key) { + (0, import_react14.useLayoutEffect)(() => { + const node = ref.current; @@ -9282,9 +9894,7 @@ diff --git a/client.js b/client.js + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); - } --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" })); ++} +function useNow(active) { + const [now, setNow] = (0, import_react14.useState)(Date.now); + (0, import_react14.useEffect)(() => { @@ -9293,23 +9903,7 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [active]); + return now; - } --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 })) -- ); --}; --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 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"], @@ -9320,16 +9914,26 @@ diff --git a/client.js b/client.js + 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_react5.createElement)( + return (0, import_react14.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (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-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-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-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")), @@ -9338,72 +9942,183 @@ diff --git a/client.js b/client.js + "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 })) -+ ) + ) ); } --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"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -+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)( +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +-} +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[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} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.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"); +-} +-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); ++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)); + }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( ++ return (0, import_react14.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 +- ); +- }) ++ { 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 - ) ++ ) ); } --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"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --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 }; +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; @@ -9444,28 +10159,7 @@ diff --git a/client.js b/client.js +@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} +@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} +`; - --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); ++ +// src/shared/task-board.js +var COLUMNS = ["not-started", "in-progress", "done"]; +var PRIORITIES = ["urgent", "high", "normal", "low"]; @@ -9578,7 +10272,11 @@ diff --git a/client.js b/client.js + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); -+ }; + }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); + }, []); + return tasks; +} @@ -9607,31 +10305,14 @@ diff --git a/client.js b/client.js + workflowPoll = null; } }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); + }, []); + return workflows; - } --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; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); ++} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -9763,24 +10444,7 @@ diff --git a/client.js b/client.js + chat: roomId ?? bot.id, + ...people(roster, bot.id) + }); - }; -- 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(); ++ }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -9821,7 +10485,8 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) -+ }; + }; +- }, [open]); + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); @@ -9830,48 +10495,8 @@ diff --git a/client.js b/client.js + const column = columnOf(task); + if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); + else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); - } -- }) : 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"))); ++ } ++ } + for (const graph of Array.isArray(workflows) ? workflows : []) { + if (graph === null || typeof graph !== "object") continue; + const seenRuns = /* @__PURE__ */ new Set(); @@ -9900,54 +10525,7 @@ diff --git a/client.js b/client.js + reason, + ...caller === void 0 ? {} : { bot: caller } + }); - } -- 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")) -- ) -- ); ++ } + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; @@ -9959,21 +10537,7 @@ diff --git a/client.js b/client.js + if (text2 === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); + } - } -- 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)) -- ); -- })) ++ } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); @@ -10014,7 +10578,18 @@ 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); @@ -10067,20 +10642,8 @@ diff --git a/client.js b/client.js + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react15.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) - ); - } --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); ++ ); ++} +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react15.useState)(0); + const question = card.kind === "question"; @@ -10098,22 +10661,24 @@ diff --git a/client.js b/client.js + onAnswer(card, { selected, custom }); return; } -- pending.answer({ answers: next }); +- 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); - }; -- 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)); +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); + if (custom === "") return; + onAnswer(card, custom); }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); +- 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) { @@ -10123,10 +10688,30 @@ 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); }; -- return (0, import_react4.createElement)( +- 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", -- { 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 }) +- { 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" }, @@ -10158,90 +10743,12 @@ 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 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 }); --} --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: [] } -- }))); --} -- --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.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 }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- 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 ")); --}; --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 }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); -- } -- }); --} --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, +-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) : {}; @@ -10253,12 +10760,7 @@ diff --git a/client.js b/client.js + return (0, import_react15.createElement)( + "button", + { - type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) ++ type: "button", + className: "bt-row bt-inbox-row", + "aria-label": label, + title: wide ? void 0 : label, @@ -10271,47 +10773,52 @@ diff --git a/client.js b/client.js + count > 0 ? (0, import_react15.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null ); } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- 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 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 SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( +-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"); @@ -10330,29 +10837,7 @@ diff --git a/client.js b/client.js + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react15.createElement)( - "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (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 of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" ++ "div", + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status, "data-run": card.kind === "workflow" ? card.run?.id : void 0 }, + (0, import_react15.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react15.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react15.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react15.createElement)( @@ -10389,35 +10874,7 @@ diff --git a/client.js b/client.js + unconfirmed ? (0, import_react15.createElement)("span", { className: "bt-inbox-writeback", title: unconfirmed.target }, t("Could not confirm the comment on {target} went out", { target: unconfirmed.target })) : null, + ...locked ? [] : [(0, import_react15.createElement)(AnswerFields, { card, busy, onAnswer }), (0, import_react15.createElement)(ResultActions, { card, busy, onAnswer })], + error ? (0, import_react15.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- 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 SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- 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)) ++ ), + (0, import_react15.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -10425,11 +10882,22 @@ diff --git a/client.js b/client.js + answerable ? (0, import_react15.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, + ...(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 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 }); +-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 useNow2(ms) { + const [now, setNow] = (0, import_react15.useState)(Date.now); + (0, import_react15.useEffect)(() => { @@ -10437,40 +10905,32 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ms]); + return now; - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- 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") }))); ++} +function openCard(actions, card) { + actions.closeInbox(); + if (card?.kind === "workflow") { + actions.openSettings("workflow"); + return; } -- return null; --} --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- 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 }))); +- 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 ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( +-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 InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -10508,33 +10968,11 @@ diff --git a/client.js b/client.js + }; + const open = (card) => openCard(actions, card); + return (0, import_react15.createElement)( - "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- 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", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( ++ "div", + { 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", -- { 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")) ++ "div", + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react15.createElement)( + "div", @@ -10584,25 +11022,13 @@ diff --git a/client.js b/client.js + ); + }) + ) - ) ++ ) ); - } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -- } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( ++} +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)} @@ -10670,16 +11096,7 @@ diff --git a/client.js b/client.js + key, + id: String(task.id), + title: String(task.title ?? "") - }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "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")) ++ }; +} + +// src/client/board-card.js @@ -10729,13 +11146,16 @@ diff --git a/client.js b/client.js + onDragEnd: draggable ? () => onDragEnd?.() : void 0 + }, + (0, import_react16.createElement)( -+ "span", + "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 + { 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 ), -- 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 +- (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, @@ -10745,27 +11165,12 @@ diff --git a/client.js b/client.js + actions ); } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- 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 GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( - "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( +- { 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 + +// src/client/kanban.js +var import_react17 = require("react"); @@ -10777,23 +11182,63 @@ 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 }); +- 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 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 })); +- }); + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + return columns; +} @@ -10845,7 +11290,10 @@ diff --git a/client.js b/client.js + rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); + walk(bot.id, depth + 1); + } -+ }; + }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); + walk("", 0); + const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); + return { bots: rows, groups }; @@ -10939,10 +11387,70 @@ diff --git a/client.js b/client.js + }; + load(); + const timer = setInterval(load, POLL_MS4); -+ return () => { + return () => { +- alive.current = false; + stopped = true; + clearInterval(timer); -+ }; + }; +- }, [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); +- } +- 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 }) + }, [key]); + const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const replace = (task) => { @@ -10969,8 +11477,33 @@ 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 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) : ""; +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); @@ -11006,9 +11539,7 @@ diff --git a/client.js b/client.js + who ? (0, import_react17.createElement)("span", { className: "bt-board-who" }, who) : null, + Number.isFinite(finished) && finished > 0 ? (0, import_react17.createElement)("span", { key: "finished", className: "bt-board-when" }, relativeShort(finished)) : null, + (0, import_react17.createElement)( - "span", -- { 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 }))) ++ "span", + { className: "bt-board-progress" }, + (0, import_react17.createElement)( + "span", @@ -11016,9 +11547,8 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)("span", { style: { width: `${progress}%` } }) + ), + (0, import_react17.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) - ) - ), -- open ? (0, import_react5.createElement)( ++ ) ++ ), + actions: [ + movable && column === "not-started" ? (0, import_react17.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, + movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react17.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null @@ -11027,37 +11557,73 @@ diff --git a/client.js b/client.js + dragValue: task.id, + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null) -+ }); -+} -+function cardProps(task, roster, onMove, onDrag, partOf2, locked) { -+ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; -+} -+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) : []; -+ const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; -+ return (0, import_react17.createElement)( -+ "section", -+ { -+ className: "bt-board-column", -+ "data-column": column, -+ "aria-label": columnTitle(column), -+ onDragOver: (event) => { -+ if (locked || !dragging) return; -+ event.preventDefault(); -+ }, -+ onDrop: (event) => { -+ if (locked) return; -+ event.preventDefault(); -+ const id = event.dataTransfer?.getData("text/plain"); -+ if (id) onMove?.(id, column); -+ onDrag?.(null); -+ } -+ }, -+ (0, import_react17.createElement)( -+ "h3", -+ { className: "bt-board-column-head" }, -+ (0, import_react17.createElement)("span", null, columnTitle(column)), + }); +- 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); ++} ++function cardProps(task, roster, onMove, onDrag, partOf2, locked) { ++ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; ++} ++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) : []; ++ const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; ++ return (0, import_react17.createElement)( ++ "section", ++ { ++ className: "bt-board-column", ++ "data-column": column, ++ "aria-label": columnTitle(column), ++ onDragOver: (event) => { ++ if (locked || !dragging) return; ++ event.preventDefault(); ++ }, ++ onDrop: (event) => { ++ if (locked) return; ++ event.preventDefault(); ++ const id = event.dataTransfer?.getData("text/plain"); ++ if (id) onMove?.(id, column); ++ onDrag?.(null); + } +- 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 +- }); ++ }, ++ (0, import_react17.createElement)( ++ "h3", ++ { className: "bt-board-column-head" }, ++ (0, import_react17.createElement)("span", null, columnTitle(column)), + // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the + // owner has, so a full page says `20 newest` instead of claiming the page is the total. + (0, import_react17.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) @@ -11065,23 +11631,11 @@ 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", -- { 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 })) }), -- (0, import_react5.createElement)( ++ "div", + { 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)( - "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (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 ++ "div", + { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, + (0, import_react17.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) + )) @@ -11093,35 +11647,15 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)("span", { className: "bt-board-lane-name" }, lane.name), + 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 AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); -- }; -- return (0, import_react5.createElement)( ++ ); ++} +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + return (0, import_react17.createElement)( - "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (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)( ++ "div", + { className: "bt-board-body" }, + (0, import_react17.createElement)( + "aside", @@ -11143,11 +11677,7 @@ diff --git a/client.js b/client.js + rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) + ), + (0, import_react17.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")) ++ "div", + { className: "bt-board-main" }, + error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, @@ -11179,32 +11709,9 @@ diff --git a/client.js b/client.js + locked + })) + ) - ) - ); - } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; ++ ) ++ ); ++} +function KanbanRow({ wide, useUi, actions }) { + const open = useUi ? useUi((value) => Boolean(value.board)) : false; + const label = t("Tasks"); @@ -11266,7 +11773,10 @@ diff --git a/client.js b/client.js + replace(await actions.taskUpdate({ id, column })); + } catch (failure) { + setError(failure?.message ?? String(failure)); -+ } + } +- 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")); + }; + const create = async (title) => { + if (locked) return; @@ -11278,7 +11788,31 @@ diff --git a/client.js b/client.js + setDraft(""); + } catch (failure) { + setError(failure?.message ?? String(failure)); -+ } + } +- 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")); +- } +-} +-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 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; + }; + const name = scopeName(roster, scope); + return (0, import_react17.createElement)( @@ -11317,7 +11851,9 @@ diff --git a/client.js b/client.js + }) + ) + ); -+} + } +-function Nothing() { +- return null; +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)} @@ -11381,100 +11917,30 @@ diff --git a/client.js b/client.js + .bt-board-columns{flex-direction:column} + .bt-board-column{min-width:0} } --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[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} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.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"); -+// src/client/role-labels.js -+var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; -+function roleText(role) { -+ if (role === null || role === void 0 || role === "") return ""; -+ return t(ROLE_LABELS[role] ?? String(role)); - } --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)( +-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}`; ++`; ++ ++// src/client/role-labels.js ++var ROLE_LABELS = { owner: "Owner", maintainer: "Maintainer", member: "Member" }; ++function roleText(role) { ++ if (role === null || role === void 0 || role === "") return ""; ++ return t(ROLE_LABELS[role] ?? String(role)); + } +-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; ++ +// src/client/projects-nav.js +var import_react18 = require("react"); +var import_react_dom3 = require("react-dom"); @@ -11510,14 +11976,46 @@ diff --git a/client.js b/client.js + for (const role of choices) { + if (role === current) continue; + items.push({ role, label: roleText(role), current: false, pick: true }); -+ } + } +- return next === void 0 ? void 0 : turns[next].first; + return items; -+} + } +-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 roleNote(answer) { + if (answer?.changed === true) return t("Role changed."); + const reason = typeof answer?.reason === "string" && answer.reason !== "" ? answer.reason : t("refused"); + return t("Role unchanged: {reason}", { reason }); -+} + } +-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 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", @@ -11538,52 +12036,90 @@ diff --git a/client.js b/client.js + roleText(project.role) === "" ? null : (0, import_react18.createElement)("span", { className: "bt-project-entry-role" }, roleText(project.role)) + )); + return (0, import_react18.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 -- ); -- }) ++ "div", + { 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) ); } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; +-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" }; +- } +- 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 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 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]); +function ProjectsMark() { + return (0, import_react18.createElement)( + "svg", @@ -11591,51 +12127,108 @@ diff --git a/client.js b/client.js + (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 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 }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +function ProjectRoleMenu({ project, x, y, onPick, onClose }) { + const ref = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { - const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); ++ const onDown = (event) => { + if (!ref.current?.contains(event.target)) onClose(); }; - const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } +- void tick(); ++ const onKey = (event) => { + if (event.key === "Escape") onClose(); - }; -- window.addEventListener("mousedown", onDown); ++ }; + window.addEventListener("mousedown", onDown, true); - window.addEventListener("keydown", onKey, true); ++ window.addEventListener("keydown", onKey, true); return () => { -- window.removeEventListener("mousedown", onDown); +- stop = true; +- clearTimeout(timer); + window.removeEventListener("mousedown", onDown, true); - window.removeEventListener("keydown", onKey, true); ++ window.removeEventListener("keydown", onKey, true); }; -- }, [open]); --} --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(); +- }, [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)( + }, [onClose]); + const items = projectMenuItems(project); + return (0, import_react_dom3.createPortal)((0, import_react18.createElement)( -+ "div", -+ { -+ ref, -+ className: "bt-project-menu", -+ role: "menu", + "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 })) +- ) +- ); ++ { ++ ref, ++ className: "bt-project-menu", ++ role: "menu", + "aria-label": t("My role"), + style: { left: `${x}px`, top: `${y}px` }, + "data-project-id": String(project?.id ?? "") @@ -11651,7 +12244,9 @@ 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); +} @@ -11660,24 +12255,8 @@ diff --git a/client.js b/client.js + const open = (0, import_react18.useCallback)((id) => { + if (onOpen) { + onOpen(id); - 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); ++ return; ++ } + if (typeof window !== "undefined") window.location.hash = projectHash(id); + }, [onOpen]); + const [expanded, setExpanded] = (0, import_react18.useState)(false); @@ -11707,41 +12286,10 @@ diff --git a/client.js b/client.js + return; + } + setExpanded((value) => !value); - }; -- 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 listed = expanded && wide; + return (0, import_react18.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 -- ); --} --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) ++ "div", + { className: "bt-projects" }, + (0, import_react18.createElement)( + "button", @@ -11773,54 +12321,32 @@ diff --git a/client.js b/client.js + } catch (failure) { + setNote(failure?.message ?? String(failure)); + } -+ } -+ }) + } +- 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 })) +- ); + }) ); } --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 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 -- ); +-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); + +// src/client/sidebar.js +var import_react19 = require("react"); @@ -11830,26 +12356,7 @@ diff --git a/client.js b/client.js + (0, import_react19.useEffect)(() => { + if (active && marked) actions.clearUnread(id); + }, [active, marked]); - } --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) -- ) -- ); -- } -- 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)( ++} +var PinMark = () => (0, import_react19.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react19.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(",")) : ""; @@ -11881,7 +12388,9 @@ 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; @@ -11889,16 +12398,7 @@ diff --git a/client.js b/client.js + const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + if (tile && wide) { + return (0, import_react19.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react19.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); --} --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( ++ "button", + { + type: "button", + className: "bt-tile", @@ -11918,14 +12418,7 @@ diff --git a/client.js b/client.js + } + const unreadMark = wide && waiting && !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; + const row = (0, import_react19.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 }) -- ); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "button", + { + type: "button", + className: "bt-row", @@ -11940,12 +12433,7 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)(BotAvatar, { bot, size: 36, state, main }), + pinned && !wide ? (0, import_react19.createElement)(PinMark) : null, + wide ? (0, import_react19.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 }) ++ "span", + { className: "bt-row-body" }, + (0, import_react19.createElement)( + "span", @@ -11957,7 +12445,7 @@ diff --git a/client.js b/client.js + secondLine ? (0, import_react19.createElement)("span", { className: "bt-row-preview" }, secondLine) : null + ) : null, + unreadMark - ); ++ ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); + const settings = wide ? (0, import_react19.createElement)("button", { + type: "button", @@ -11983,12 +12471,14 @@ diff --git a/client.js b/client.js + reset(); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); } --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 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 RemoteMark({ remote }) { + return (0, import_react19.createElement)( + "span", @@ -11999,27 +12489,106 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react19.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ) - ); ++ ); } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +-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]; -- 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) +- 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 isPastFaceRoom(room2) { + if (room2?.origin !== "face-to-face") return false; + const status = room2.face?.status; + if (status === "closed" || status === "left") return true; + 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 FaceMark() { + return (0, import_react19.createElement)( + "span", @@ -12031,10 +12600,19 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react19.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) - ); ++ ); } --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); +- +-// 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`; +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)); @@ -12084,38 +12662,27 @@ diff --git a/client.js b/client.js + 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 + ); } --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 })); +-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); - }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); +- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); +- find(); - return () => { -- alive.current = false; +- cancelAnimationFrame(frame); +- observer.disconnect(); +function AgentSessionRow({ session, agent, actions, renaming, index }) { + const input = (0, import_react19.useRef)(null); + (0, import_react19.useLayoutEffect)(() => { @@ -12139,27 +12706,8 @@ diff --git a/client.js b/client.js + } + })(); }; -- }, [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); +- }, [active]); +- return host; + return (0, import_react19.createElement)( + "div", + { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, @@ -12175,8 +12723,7 @@ diff --git a/client.js b/client.js + onBlur: () => commit(true) + }) + ); - } -- return (0, import_react6.createElement)( ++ } + return (0, import_react19.createElement)( + "button", + { @@ -12194,7 +12741,19 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("span", { className: "bt-agent-sub-title" }, session.title), + (0, import_react19.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + ); -+} + } +-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); +function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { + const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { + const row = list.byId[id]; @@ -12205,7 +12764,29 @@ diff --git a/client.js b/client.js + at: row?.updatedAt ?? 0, + running: row?.running === true, + active: (row?.retainedBy?.mainView ?? 0) > 0 -+ }; + }; +- 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); + }).sort((a, b) => b.at - a.at))); + const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ + id, @@ -12228,36 +12809,7 @@ 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_react19.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 }); ++ "div", + { className: "bt-agent" }, + (0, import_react19.createElement)( + "button", @@ -12272,12 +12824,7 @@ diff --git a/client.js b/client.js + onContextMenu: (event) => { + event.preventDefault(); + actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); - } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ } + }, + (0, import_react19.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react19.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), + wide ? (0, import_react19.createElement)( @@ -12312,64 +12859,8 @@ diff --git a/client.js b/client.js + })) + ) + ) - ); - } --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); ++ ); ++} +var GLIDE = springEasing(1e3, 63); +function useListGlide(listRef, layout, key) { + const tops = (0, import_react19.useRef)({ layout, map: /* @__PURE__ */ new Map() }); @@ -12386,99 +12877,26 @@ diff --git a/client.js b/client.js + if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { + row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); } -- 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")); - } -- 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")); -- } --} --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 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 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}`; +- }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(scan); +- }); +- scan(); +- return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); +- }; +- }, []); ++ } + tops.current = { layout, map: next }; + }, [layout, key]); } --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 ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { +var NodeActions = class extends import_react19.Component { + constructor(props) { + super(props); + this.state = { failed: false }; - } -- return next === void 0 ? void 0 : turns[next].first; --} --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; --} --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, ++ } + static getDerivedStateFromError() { + return { failed: true }; + } @@ -12494,9 +12912,7 @@ diff --git a/client.js b/client.js + const closed = collapsed[bot.id] === true; + return (0, import_react19.createElement)( + import_react19.Fragment, - null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null ++ null, + (0, import_react19.createElement)( + "div", + { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, @@ -12509,54 +12925,51 @@ 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 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 markedUnread = useUi((value) => value.unread); + props.useRegistry((value) => value); + const updated = useSessions((list) => { @@ -12585,35 +12998,38 @@ diff --git a/client.js b/client.js + onClick: () => { + void Promise.resolve(actions.reloadTeam?.()).catch((error) => actions.reportError(error, "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]); +- 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")); + const structure = useSidebarStructure(); + const [collapsed, setCollapsed] = (0, import_react19.useState)({}); @@ -12631,7 +13047,18 @@ diff --git a/client.js b/client.js + actions.openPalette(event.key); + }; + return (0, import_react19.createElement)( -+ "div", + "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 +- ) + { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, + wide ? (0, import_react19.createElement)( + "div", @@ -12659,24 +13086,59 @@ 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]); + ); } --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 }); +-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 UpdateButton({ useUpdate, actions }) { + const update = useUpdate((value) => value); + const [open, setOpen] = (0, import_react19.useState)(false); @@ -12695,68 +13157,14 @@ 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 () => { -- stop = true; -- clearTimeout(timer); ++ return () => { + 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; @@ -12768,7 +13176,30 @@ 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)))); @@ -12786,10 +13217,26 @@ diff --git a/client.js b/client.js + null, + (0, import_react19.createElement)("button", { + ref: button, -+ type: "button", + type: "button", +- className: "bt-row", + className: "bt-round bt-update", + "data-phase": cloudPhase, -+ "aria-label": label, + "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 + title: label, + "aria-haspopup": "dialog", + "aria-expanded": open, @@ -12805,25 +13252,7 @@ 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", -- { 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)( ++ "div", + { 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", { @@ -12835,12 +13264,7 @@ diff --git a/client.js b/client.js + ) + ) : null, + dshRow ? (0, import_react19.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 })) -- ); -- }) ++ "div", + { 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), @@ -12860,134 +13284,43 @@ diff --git a/client.js b/client.js + ), document.body) : 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 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 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; +-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 }); - } -- 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 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)))); --} -- --// 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`; +- }, +- (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 +function ConnectApps({ wide, actions }) { + if (!wide) return null; + return (0, import_react19.createElement)( @@ -13000,21 +13333,28 @@ diff --git a/client.js b/client.js + t("Connect apps") + ) + ); - } --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); -- (0, import_react7.useEffect)(() => { -- if (!active) { -- setHost(null); -- return void 0; ++} +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; +var openShellSettings = null; +var openShellOnboarding = null; +var shellShortcuts = null; @@ -13029,74 +13369,12 @@ diff --git a/client.js b/client.js + if (openShellSettings) { + openShellSettings(); + return; - } -- 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(); -- }; -- }, [active]); -- return host; ++ } + const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); + if (desktop) openThroughMenu(desktop); + else pressSettingsShortcut(); + }); - } --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); -- } -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); -- }); -- scan(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- }; ++} +function openHarnessOnboarding(id) { + requestAnimationFrame(() => { + if (openShellOnboarding) openShellOnboarding(id); @@ -13109,109 +13387,12 @@ diff --git a/client.js b/client.js + (0, import_react19.useLayoutEffect)(() => { + if (explicit) openSection("models"); + complete(); - }, []); ++ }, []); + return 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(); -- 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; -- } -- 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)( -- "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); ++} +function openHarnessModels() { + openHarnessOnboarding(MODELS_STEP_ID); - } -- --// 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); ++} +function openThroughMenu(trigger) { + const before = new Set(document.querySelectorAll('[role="menu"]')); + trigger.click(); @@ -13224,16 +13405,7 @@ diff --git a/client.js b/client.js + else pressSettingsShortcut(); + }; + requestAnimationFrame(look); - } -- --// 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 pressSettingsShortcut() { + const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; + if (!binding) return; @@ -13248,36 +13420,7 @@ diff --git a/client.js b/client.js + shiftKey: held.has("shift"), + metaKey: held.has("meta") + })); - } --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 AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { + const [open, setOpen] = (0, import_react19.useState)(false); + (0, import_react19.useLayoutEffect)(() => { @@ -13315,8 +13458,17 @@ diff --git a/client.js b/client.js + 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)( + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { @@ -13328,11 +13480,7 @@ diff --git a/client.js b/client.js + const rect = button.current?.getBoundingClientRect(); + if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); + 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)( ++ }; + const groups = [ + [ + [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -13349,48 +13497,27 @@ diff --git a/client.js b/client.js + import_react19.Fragment, + null, + (0, import_react19.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", -- { -- type: "button", -- 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)(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 ++ "button", + { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, + (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", + "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)( + { 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)( @@ -13408,46 +13535,8 @@ diff --git a/client.js b/client.js + }, (0, import_react19.createElement)(Icon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react19.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) + )) + ), 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 -- ); ++ ); ++} +function useLandOnMainBot(roster, useSessions, actions) { + const mainView = useSessions((list) => { + const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); @@ -13471,47 +13560,7 @@ diff --git a/client.js b/client.js + const timer = setTimeout(() => actions.landOn(roster.mainBotId, last, { stay: id === "", view: id }), last ? 0 : 400); + return () => clearTimeout(timer); + }, [mainView, roster]); - } --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)( ++} +function FooterAccount(props) { + const seated = props.useUi((value) => value.accountSeated === true); + if (seated) return null; @@ -13683,6 +13732,15 @@ 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) => ({ @@ -13706,15 +13764,6 @@ 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; +} @@ -14379,49 +14428,15 @@ diff --git a/client.js b/client.js - dshRow ? (0, import_react8.createElement)( + { className: "bt-project-metric-cards" }, + view.cards.map((card) => (0, import_react21.createElement)( - "div", -- { className: "bt-update-dsh" }, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), -- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: dshPhase === "installing", -- onClick: startDsh -- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null -- ), document.body) : null ++ "div", + { key: card.key, className: "bt-project-metric", "data-metric": card.key }, + (0, import_react21.createElement)("span", { className: "bt-project-metric-value" }, card.value), + (0, import_react21.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)), + (0, import_react21.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption)) + )) + ) - ); - } --function ConnectPlugins({ wide, actions }) { -- const tipId = (0, import_react8.useId)(); -- const [tip, setTip] = (0, import_react8.useState)(false); -- const timer = (0, import_react8.useRef)(0); -- const button = (0, import_react8.useRef)(null); -- const [box, setBox] = (0, import_react8.useState)(null); -- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); -- if (!wide) return null; -- const show = () => { -- clearTimeout(timer.current); -- timer.current = setTimeout(() => { -- const rect = button.current?.getBoundingClientRect(); -- if (!rect) return; -- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); -- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); -- setTip(true); -- }, 250); ++ ); ++} +function ProjectRunningTable({ view, onOpen }) { + if (view.running.length === 0) return (0, import_react21.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react21.createElement)("h4", null, t("Running tasks")), (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running."))); + return (0, import_react21.createElement)( @@ -14441,7 +14456,24 @@ diff --git a/client.js b/client.js + view.running.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + return (0, import_react21.createElement)( -+ "div", + "div", +- { className: "bt-update-dsh" }, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), +- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, +- (0, import_react8.createElement)( +- "div", +- { className: "bt-update-actions" }, +- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { +- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { +- type: "button", +- className: "bt-update-go", +- disabled: dshPhase === "installing", +- onClick: startDsh +- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) +- ) +- ) : null +- ), document.body) : null + { + key: row.key, + className: "bt-project-running-row", @@ -14459,8 +14491,25 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale())) + ); + }) -+ ); -+} + ); + } +-function ConnectPlugins({ wide, actions }) { +- const tipId = (0, import_react8.useId)(); +- const [tip, setTip] = (0, import_react8.useState)(false); +- const timer = (0, import_react8.useRef)(0); +- const button = (0, import_react8.useRef)(null); +- const [box, setBox] = (0, import_react8.useState)(null); +- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); +- if (!wide) return null; +- const show = () => { +- clearTimeout(timer.current); +- timer.current = setTimeout(() => { +- const rect = button.current?.getBoundingClientRect(); +- if (!rect) return; +- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); +- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); +- setTip(true); +- }, 250); +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map((entry) => (0, import_react21.createElement)( + "section", @@ -14827,21 +14876,6 @@ diff --git a/client.js b/client.js + if (target !== "") onOpen?.(target); }; - 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") -- })); + if (id === "") return null; + return (0, import_react21.createElement)("button", { + type: "button", @@ -14886,56 +14920,20 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); } --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 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, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react21.useState)("bot"); @@ -14970,25 +14968,7 @@ diff --git a/client.js b/client.js + setStatus(hostText(error?.message ?? String(error))); + return null; + } - }; -- 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 })) ++ }; + return (0, import_react21.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, @@ -15021,17 +15001,9 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("label", null, (0, import_react21.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")), + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save")) + ) - ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ ), + (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, ++ "div", + { className: "bt-project-field" }, + // The host knows two memory kinds; the free text the panel takes is recorded as a decision, + // never as a third kind the host would refuse (#1820). @@ -15042,20 +15014,16 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-project-row-actions" }, + (0, import_react21.createElement)("button", { - type: "button", -- role: "menuitem", ++ type: "button", + 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: () => { -- setOpen(false); -- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); ++ onClick: () => { + 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")) + ) + ), @@ -15066,13 +15034,81 @@ 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 = []; @@ -15105,13 +15141,29 @@ 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")) - )) -- ), document.body) : null -+ ), ++ )) + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + 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", + "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 + { className: "bt-project-row-actions" }, + (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) + ) @@ -16155,10 +16207,57 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -6121,233 +6121,9 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - }; - } - +@@ -6074,280 +6074,56 @@ async function openCapture() { + }, + 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"; +- } +- if (!spokeAt) return "waiting"; +- if (level < quiet) quietSince ||= now; +- return quietSince && now - quietSince >= quietMs ? "done" : "speech"; +- }; +-} +- -// src/client/overlay-hooks.js -var import_react11 = require("react"); -var escapeLayers = []; @@ -16381,8 +16480,53 @@ diff --git a/client.js b/client.js - ) - ) - ); --} -- ++ 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 -var import_react13 = require("react"); -var import_react_dom6 = require("react-dom"); @@ -16587,33 +16731,10 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react24.createElement)(SlidersIcon), t("Manage models")) ]; - return (0, import_react13.createElement)( -+ return (0, import_react24.createElement)( -+ "div", -+ { className: "bt-model-dock" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-msg" }, -+ (0, import_react24.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react24.createElement)("div", { className: "bt-bubble" }, text2) -+ ) -+ ) -+ ), -+ (0, import_react24.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), -+ error ? (0, import_react24.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ ); -+} -+function RemoteGoneDock({ bot }) { + return (0, import_react24.createElement)( "div", -- { className: "bt-model-dock" }, + { className: "bt-model-dock" }, - (0, import_react13.createElement)( -+ { className: "bt-model-dock", "data-flybot": "remote-gone" }, + (0, import_react24.createElement)( "div", { className: "bt-astack", "data-lead": "" }, @@ -16628,12 +16749,33 @@ diff --git a/client.js b/client.js - (0, import_react13.createElement)(BotMark, { bot, size: 22 }), - (0, import_react13.createElement)("div", { className: "bt-bubble" }, text) + (0, import_react24.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react24.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ (0, import_react24.createElement)("div", { className: "bt-bubble" }, text2) ) ) -- ), + ), - (0, import_react13.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ (0, import_react24.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), ++ error ? (0, import_react24.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ ); ++} ++function RemoteGoneDock({ bot }) { ++ return (0, import_react24.createElement)( ++ "div", ++ { className: "bt-model-dock", "data-flybot": "remote-gone" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": "" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-msg" }, ++ (0, import_react24.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ (0, import_react24.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react24.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ ) ++ ) + ) ); } @@ -16727,424 +16869,54 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6669,501 +6669,251 @@ var MODEL_CSS = ` - .bt-model-chip:disabled{cursor:default} - .bt-model-chip:disabled:not([aria-busy]){opacity:.5} - .bt-model-error{margin-left:30px} --@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} --.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} --@keyframes bt-model-spin{to{transform:rotate(360deg)}} --@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} --`; -- --// src/client/folder-menu.js --var import_react14 = require("react"); --var import_react_dom7 = require("react-dom"); --var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); --var Tick = ({ on }) => (0, import_react14.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react14.createElement)(CheckIcon) : null); --function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { -- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); -- const [info, setInfo] = (0, import_react14.useState)(null); -- const [view, setView] = (0, import_react14.useState)("list"); -- const [draft, setDraft] = (0, import_react14.useState)(""); -- const [listing, setListing] = (0, import_react14.useState)(null); -- const [problem, setProblem] = (0, import_react14.useState)(""); -- const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); -- const toggle = () => { -- if (isOpen) { -- close(); -- return; -- } -- setView("list"); -- setProblem(""); -- void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); -- open(); -- }; -- const choose = (path) => { -- close(); -- if (path !== (value ?? "")) void onPick(path); -- }; -- const browse = (path) => { -- setProblem(""); -- void Promise.resolve(actions.folderList(path)).then((next) => { -- setListing(next); -- setView("browse"); -- }).catch((failure) => setProblem(failure?.message ?? String(failure))); -- }; -- const pickNative = () => { -- close(); -- void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0)); -- }; -- const typed = () => { -- const path = draft.trim(); -- if (path !== "") choose(path); -- }; -- const known = info?.folders ?? []; -- const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; -- const listView = (0, import_react14.createElement)( -- "div", -- { key: "list", className: "bt-folder-view" }, -- (0, import_react14.createElement)( -- "button", -- { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, -- (0, import_react14.createElement)("span", { className: "bt-menu-label" }, fallback), -- (0, import_react14.createElement)(Tick, { on: !value }) -- ), -- folders.length > 0 ? (0, import_react14.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, -- folders.map((folder) => (0, import_react14.createElement)( -- "button", -- { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, -- (0, import_react14.createElement)("span", { className: "bt-folder-copy" }, (0, import_react14.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react14.createElement)("span", { className: "bt-folder-path" }, folder.path)), -- (0, import_react14.createElement)(Tick, { on: folder.path === value }) -- )), -- (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -- info?.picker === "native" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, -- info?.picker === "browse" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, -- (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -- setDraft(value ?? ""); -- setView("type"); -- } }, (0, import_react14.createElement)(PencilIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) -- ); -- const typeView = (0, import_react14.createElement)( -- "div", -- { key: "type", 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)("input", { -- className: "bt-folder-input", -- autoFocus: true, -- value: draft, -- spellCheck: false, -- "aria-label": t("Folder path"), -- placeholder: t("Full path of a folder"), -- onChange: (event) => setDraft(event.target.value), -- onKeyDown: (event) => { -- 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")) -- ); -- 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)( -- "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)( -- "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_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)) -- ); --} --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); -+@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}} -+.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite} -+@keyframes bt-model-spin{to{transform:rotate(360deg)}} -+@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} -+`; -+ -+// src/client/model-test.js -+var import_react25 = require("react"); -+var FAILURE_COPY = { -+ NO_MODEL: "Pick a model first.", -+ NO_KEY: "This model has no API key yet. Set one up in the model settings.", -+ AUTH: "The API key was rejected. Check it in the model settings.", -+ QUOTA: "The account is out of quota.", -+ RATE_LIMIT: "Too many requests right now. Try again in a moment.", -+ TIMEOUT: "The model did not answer within 30 seconds.", -+ NOT_SERVED: "This model is not available now. Check its provider in the model settings.", -+ MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.", -+ TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.", -+ SERVER: "The model service failed. Try again later.", -+ INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.", -+ UNKNOWN: "The test failed." -+}; -+function testOutcome(result, modelName) { -+ if (result?.ok === true) { -+ const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0; -+ return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) }; -+ } -+ const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN"; -+ return { ok: false, code, text: t(FAILURE_COPY[code]) }; -+} -+var testModel = (id) => call("test-model", { id }); -+function ModelTestRow({ bot, modelName, test = testModel }) { -+ const [state, setState] = (0, import_react25.useState)({ busy: false, outcome: void 0 }); -+ const live = (0, import_react25.useRef)(true); -+ (0, import_react25.useEffect)(() => () => { -+ live.current = false; -+ }, []); -+ (0, import_react25.useEffect)(() => { -+ setState({ busy: false, outcome: void 0 }); -+ }, [bot.id, bot.model]); -+ const start = () => { -+ if (state.busy) return; -+ setState({ busy: true, outcome: void 0 }); -+ Promise.resolve().then(() => test(bot.id)).then( -+ (result) => testOutcome(result, modelName ?? result?.ref ?? ""), -+ (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) }) -+ ).then((outcome2) => { -+ if (live.current) setState({ busy: false, outcome: outcome2 }); - }); - }; -- 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 +@@ -6675,18 +6675,96 @@ var MODEL_CSS = ` + @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} + `; + ++// src/client/model-test.js ++var import_react25 = require("react"); ++var FAILURE_COPY = { ++ NO_MODEL: "Pick a model first.", ++ NO_KEY: "This model has no API key yet. Set one up in the model settings.", ++ AUTH: "The API key was rejected. Check it in the model settings.", ++ QUOTA: "The account is out of quota.", ++ RATE_LIMIT: "Too many requests right now. Try again in a moment.", ++ TIMEOUT: "The model did not answer within 30 seconds.", ++ NOT_SERVED: "This model is not available now. Check its provider in the model settings.", ++ MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.", ++ TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.", ++ SERVER: "The model service failed. Try again later.", ++ INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.", ++ UNKNOWN: "The test failed." ++}; ++function testOutcome(result, modelName) { ++ if (result?.ok === true) { ++ const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0; ++ return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) }; ++ } ++ const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN"; ++ return { ok: false, code, text: t(FAILURE_COPY[code]) }; ++} ++var testModel = (id) => call("test-model", { id }); ++function ModelTestRow({ bot, modelName, test = testModel }) { ++ const [state, setState] = (0, import_react25.useState)({ busy: false, outcome: void 0 }); ++ const live = (0, import_react25.useRef)(true); ++ (0, import_react25.useEffect)(() => () => { ++ live.current = false; ++ }, []); ++ (0, import_react25.useEffect)(() => { ++ setState({ busy: false, outcome: void 0 }); ++ }, [bot.id, bot.model]); ++ const start = () => { ++ if (state.busy) return; ++ setState({ busy: true, outcome: void 0 }); ++ Promise.resolve().then(() => test(bot.id)).then( ++ (result) => testOutcome(result, modelName ?? result?.ref ?? ""), ++ (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) }) ++ ).then((outcome2) => { ++ if (live.current) setState({ busy: false, outcome: outcome2 }); ++ }); ++ }; + const { busy, outcome } = state; + return (0, import_react25.createElement)( + "div", @@ -17158,27 +16930,11 @@ diff --git a/client.js b/client.js + { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, + busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, + outcome?.code ? (0, import_react25.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : 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")) -- ) ++ ) ++ ), + (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) - ); - } --function AutomationOff() { -- return (0, import_react15.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")) -- ); ++ ); ++} +var MODEL_TEST_CSS = ` +.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)} +.bt-model-test-result:empty{display:none} @@ -17192,147 +16948,95 @@ diff --git a/client.js b/client.js +@container bt-panel (max-width:300px){ + .bt-model-test{flex-wrap:wrap;row-gap:6px} + .bt-model-test>.bt-set-copy{flex:1 1 100%} - } --function SchedulesPage({ roster, actions, start }) { -- const view = useSchedules(actions); -- const [adding, setAdding] = (0, import_react15.useState)(Boolean(start)); -- if (view === null) return (0, import_react15.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); -- if (!view.available) return (0, import_react15.createElement)(AutomationOff); -- const shown = (next) => { -- publish(next); -- setAdding(false); ++} +`; + -+// src/client/folder-menu.js + // src/client/folder-menu.js +-var import_react14 = require("react"); +-var import_react_dom7 = require("react-dom"); +var import_react26 = require("react"); +var import_react_dom9 = require("react-dom"); -+var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); + var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); +-var Tick = ({ on }) => (0, import_react14.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react14.createElement)(CheckIcon) : null); +var Tick = ({ on }) => (0, import_react26.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react26.createElement)(CheckIcon) : null); -+function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { -+ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); + function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { + const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); +- const [info, setInfo] = (0, import_react14.useState)(null); +- const [view, setView] = (0, import_react14.useState)("list"); +- const [draft, setDraft] = (0, import_react14.useState)(""); +- const [listing, setListing] = (0, import_react14.useState)(null); +- const [problem, setProblem] = (0, import_react14.useState)(""); + const [info, setInfo] = (0, import_react26.useState)(null); + const [view, setView] = (0, import_react26.useState)("list"); + const [draft, setDraft] = (0, import_react26.useState)(""); + const [listing, setListing] = (0, import_react26.useState)(null); + const [problem, setProblem] = (0, import_react26.useState)(""); -+ const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); -+ const toggle = () => { -+ if (isOpen) { -+ close(); -+ return; -+ } -+ setView("list"); -+ setProblem(""); -+ void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] })); -+ open(); + const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); + const toggle = () => { + if (isOpen) { +@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = 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)]) -- ].filter(([, , , tasks]) => tasks.length > 0); -- 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)( -- "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 }))) -- )) -- ]; --} --function BotSchedules({ bot, roster, actions }) { -- const view = useSchedules(actions); -- if (view === null) return null; -- const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; -- return (0, import_react15.createElement)( -- "section", -- { className: "bt-set-section" }, -- (0, import_react15.createElement)("h3", null, t("Scheduled tasks")), -- (0, import_react15.createElement)( -- "div", -- { className: "bt-set-card" }, -- !view.available ? (0, import_react15.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (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")) -- ) : [ -- ...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")) -- ] -- ) -+ const choose = (path) => { -+ close(); -+ if (path !== (value ?? "")) void onPick(path); -+ }; -+ const browse = (path) => { -+ setProblem(""); -+ void Promise.resolve(actions.folderList(path)).then((next) => { -+ setListing(next); -+ setView("browse"); -+ }).catch((failure) => setProblem(failure?.message ?? String(failure))); -+ }; -+ const pickNative = () => { -+ close(); -+ void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0)); -+ }; -+ const typed = () => { -+ const path = draft.trim(); -+ if (path !== "") choose(path); -+ }; -+ const known = info?.folders ?? []; -+ const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; + const known = info?.folders ?? []; + const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; +- const listView = (0, import_react14.createElement)( + const listView = (0, import_react26.createElement)( -+ "div", -+ { key: "list", className: "bt-folder-view" }, + "div", + { key: "list", className: "bt-folder-view" }, +- (0, import_react14.createElement)( + (0, import_react26.createElement)( -+ "button", -+ { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, + "button", + { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, +- (0, import_react14.createElement)("span", { className: "bt-menu-label" }, fallback), +- (0, import_react14.createElement)(Tick, { on: !value }) + (0, import_react26.createElement)("span", { className: "bt-menu-label" }, fallback), + (0, import_react26.createElement)(Tick, { on: !value }) -+ ), + ), +- folders.length > 0 ? (0, import_react14.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, +- folders.map((folder) => (0, import_react14.createElement)( + folders.length > 0 ? (0, import_react26.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, + folders.map((folder) => (0, import_react26.createElement)( -+ "button", -+ { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, + "button", + { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, +- (0, import_react14.createElement)("span", { className: "bt-folder-copy" }, (0, import_react14.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react14.createElement)("span", { className: "bt-folder-path" }, folder.path)), +- (0, import_react14.createElement)(Tick, { on: folder.path === value }) + (0, import_react26.createElement)("span", { className: "bt-folder-copy" }, (0, import_react26.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react26.createElement)("span", { className: "bt-folder-path" }, folder.path)), + (0, import_react26.createElement)(Tick, { on: folder.path === value }) -+ )), + )), +- (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }), +- info?.picker === "native" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, +- info?.picker === "browse" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, +- (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + (0, import_react26.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + info?.picker === "native" ? (0, import_react26.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react26.createElement)(FolderIcon), (0, import_react26.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, + info?.picker === "browse" ? (0, import_react26.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react26.createElement)(FolderIcon), (0, import_react26.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, + (0, import_react26.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -+ setDraft(value ?? ""); -+ setView("type"); + setDraft(value ?? ""); + setView("type"); +- } }, (0, import_react14.createElement)(PencilIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) + } }, (0, import_react26.createElement)(PencilIcon), (0, import_react26.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) ); --} --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)( +- const typeView = (0, import_react14.createElement)( + const typeView = (0, import_react26.createElement)( -+ "div", -+ { key: "type", className: "bt-folder-view" }, + "div", + { key: "type", 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)("input", { + (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)("input", { -+ className: "bt-folder-input", -+ autoFocus: true, -+ value: draft, -+ spellCheck: false, -+ "aria-label": t("Folder path"), -+ placeholder: t("Full path of a folder"), -+ onChange: (event) => setDraft(event.target.value), -+ onKeyDown: (event) => { -+ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); -+ } -+ }), + className: "bt-folder-input", + autoFocus: true, + value: draft, +@@ -6759,1401 +6759,1902 @@ 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 browseView = listing ? (0, import_react26.createElement)( - "div", ++ "div", + { key: `browse:${listing.path}`, className: "bt-folder-view" }, + (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), @@ -17352,16 +17056,9 @@ diff --git a/client.js b/client.js + const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView; + return (0, import_react26.createElement)( + import_react26.Fragment, - 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)( ++ null, + (0, import_react26.createElement)( - "button", -- { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, -- (0, import_react15.createElement)(ClockIcon), -- (0, import_react15.createElement)("span", { className: "bt-task-line-title" }, task.title), -- (0, import_react15.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) -- ))) ++ "button", + { + ref: button, + type: "button", @@ -17387,45 +17084,8 @@ diff --git a/client.js b/client.js + shownView, + problem ? (0, import_react26.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null + ), document.body) : null - ); - } --var SCHEDULE_CSS = ` --.bt-task{gap:10px} --.bt-task[data-done]{opacity:.6} --.bt-task[data-busy]{opacity:.4} --.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} --.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} --.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} --.bt-task-remove svg{width:14px;height:14px} --.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} --.bt-task-head{display:flex;align-items:center;gap:6px} --.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} --.bt-task-bot{width:auto;min-width:160px;max-width:58%} --.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} --.bt-task-when .bt-input{width:auto;height:32px} --.bt-task-time{min-width:110px} --.bt-task-count{width:72px!important} --.bt-task-unit{width:auto;height:32px} --.bt-task-every{font-size:13px;color:var(--bt-ink-2)} --.bt-task-days{display:flex;gap:4px} --.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} --.bt-task-foot{justify-content:flex-end;gap:8px} --.bt-task-foot .bt-send{height:28px;font-size:12px} --.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} --.bt-task-off>*+*{border-top:0} --.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} --.bt-task-off-mark svg{width:20px;height:20px} --.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} --.bt-task-off .bt-set-hint{max-width:380px} --.bt-task-list{display:flex;flex-direction:column;gap:2px} --.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} --.bt-task-line:hover{background:var(--bt-hover)} --.bt-task-line svg{flex:none;color:var(--bt-ink-3)} --.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} --@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} ++ ); ++} +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} @@ -17439,235 +17099,312 @@ diff --git a/client.js b/client.js +.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/capability-settings.js --var import_react16 = require("react"); ++`; ++ ++// src/client/capability-settings.js +var import_react27 = require("react"); - var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); --var parseMap = (text, label) => { ++var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); +var parseMap = (text2, label) => { - let value; - try { -- value = JSON.parse(text || "{}"); ++ let value; ++ try { + value = JSON.parse(text2 || "{}"); - } catch { - throw new Error(t("{label} must be valid JSON.", { label })); - } -@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { - }; - var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); - function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { -- return (0, import_react16.createElement)( ++ } catch { ++ throw new Error(t("{label} must be valid JSON.", { label })); ++ } ++ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label })); ++ return value; ++}; ++var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); ++function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { + return (0, import_react27.createElement)( - "label", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t(label))), -- (0, import_react16.createElement)(multiline ? "textarea" : "input", { ++ "label", ++ { 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(label))), + (0, import_react27.createElement)(multiline ? "textarea" : "input", { - className: multiline ? "bt-input bt-input-area" : "bt-input", - value, - disabled, -@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl - ); - } - function CapabilityForm({ draft, setDraft, items, actions, onClose }) { -- const [error, setError] = (0, import_react16.useState)(""); ++ className: multiline ? "bt-input bt-input-area" : "bt-input", ++ value, ++ disabled, ++ maxLength: label === "Name" ? 32 : void 0, ++ placeholder, ++ spellCheck: false, ++ onChange: (event) => onChange(event.target.value) ++ }) ++ ); ++} ++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(); -@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { - if (draft.kind === "mcp" && draft.transport === "stdio") { - payload.transport = "stdio"; - payload.command = draft.command; -- payload.args = draft.argsText.split("\n").filter((line) => line !== ""); ++ const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); ++ const submit = async (event) => { ++ event.preventDefault(); ++ setError(""); ++ let payload; ++ try { ++ payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description }; ++ if (draft.kind === "mcp" && draft.transport === "stdio") { ++ payload.transport = "stdio"; ++ payload.command = draft.command; + payload.args = draft.argsText.split("\n").filter((line2) => line2 !== ""); - payload.env = parseMap(draft.envText, t("Environment")); - } else if (draft.kind === "mcp") { - payload.transport = "streamable-http"; -@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { - } - }; - const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); -- return (0, import_react16.createElement)( ++ payload.env = parseMap(draft.envText, t("Environment")); ++ } else if (draft.kind === "mcp") { ++ payload.transport = "streamable-http"; ++ payload.url = draft.url; ++ payload.headers = parseMap(draft.headersText, t("Headers")); ++ } else if (draft.kind === "skill") payload.content = draft.content; ++ else payload.items = draft.items; ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ return; ++ } ++ try { ++ await actions.saveCapability(payload); ++ onClose(); ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ } ++ }; ++ const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); + return (0, import_react27.createElement)( - "form", - { className: "bt-connector-form", onSubmit: submit }, -- (0, import_react16.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), -- (0, import_react16.createElement)( ++ "form", ++ { 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", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Type"))), -- (0, import_react16.createElement)( ++ "label", ++ { 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", - { 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_react16.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) ++ "select", ++ { 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_react16.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), -- draft.kind === "mcp" ? (0, import_react16.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" }, -- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), -- (0, import_react16.createElement)( ++ "label", ++ { 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("Connection"))), + (0, import_react27.createElement)( - "select", - { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) }, -- (0, import_react16.createElement)("option", { value: "stdio" }, t("Local command")), -- (0, import_react16.createElement)("option", { value: "streamable-http" }, t("HTTP server")) ++ "select", ++ { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) }, + (0, import_react27.createElement)("option", { value: "stdio" }, t("Local command")), + (0, import_react27.createElement)("option", { value: "streamable-http" }, t("HTTP server")) - ) - ) : null, -- draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react16.createElement)( ++ ) ++ ) : null, + draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react27.createElement)( - "div", - null, -- (0, import_react16.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), -- (0, import_react16.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), -- (0, import_react16.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ++ "div", ++ 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 }), + (0, import_react27.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) - ) : null, -- draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react16.createElement)( ++ ) : null, + draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react27.createElement)( - "div", - null, -- (0, import_react16.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), -- (0, import_react16.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ++ "div", ++ null, + (0, import_react27.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), + (0, import_react27.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) - ) : null, -- draft.kind === "skill" ? (0, import_react16.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, -- draft.kind === "pack" ? (0, import_react16.createElement)( ++ ) : null, + draft.kind === "skill" ? (0, import_react27.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, + draft.kind === "pack" ? (0, import_react27.createElement)( - "div", - { className: "bt-set-card" }, -- available.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react16.createElement)( ++ "div", ++ { className: "bt-set-card" }, + available.length === 0 ? (0, import_react27.createElement)("div", { className: "bt-set-row" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react27.createElement)( - "label", - { key: item.name, className: "bt-set-row bt-bs-row" }, -- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, item.name)), -- (0, import_react16.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) ++ "label", ++ { key: item.name, className: "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, item.name)), + (0, import_react27.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) - )) - ) : null, -- error ? (0, import_react16.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, -- (0, import_react16.createElement)( ++ )) ++ ) : null, + error ? (0, import_react27.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, + (0, import_react27.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), -- (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) ++ "div", ++ { className: "bt-connector-form-row" }, + (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")) - ) ++ ) ); - } - 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_react16.createElement)( +- 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", - { className: "bt-connector" }, -- (0, import_react16.createElement)( +- { 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)( ++ { className: "bt-connector" }, + (0, import_react27.createElement)( - "div", - { className: "bt-connector-head" }, -- (0, import_react16.createElement)( ++ "div", ++ { className: "bt-connector-head" }, + (0, import_react27.createElement)( "span", - { className: "bt-connector-text" }, -- (0, import_react16.createElement)("span", { className: "bt-connector-name" }, item.name), -- (0, import_react16.createElement)("span", { className: "bt-connector-status" }, secondary) +- { 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 ++ { 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_react16.createElement)( +- (0, import_react14.createElement)(Caret) + (0, import_react27.createElement)( - "span", - { className: "bt-connector-actions" }, -- item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, -- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), -- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) ++ "span", ++ { className: "bt-connector-actions" }, + item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) - ) - ) - ); - } - function CapabilitiesPage({ roster, actions, run }) { - const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name)); -- const [draft, setDraft] = (0, import_react16.useState)(null); -- const [confirming, setConfirming] = (0, import_react16.useState)(""); -- (0, import_react16.useEffect)(() => { ++ ) ++ ) ++ ); ++} ++function CapabilitiesPage({ roster, actions, run }) { ++ const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name)); + const [draft, setDraft] = (0, import_react27.useState)(null); + const [confirming, setConfirming] = (0, import_react27.useState)(""); + (0, import_react27.useEffect)(() => { - if (!confirming) return void 0; - const timer = setTimeout(() => setConfirming(""), 4e3); - return () => clearTimeout(timer); -@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { - setConfirming(""); - void run(actions.deleteCapability(item.name)); - }; -- return (0, import_react16.createElement)( ++ if (!confirming) return void 0; ++ const timer = setTimeout(() => setConfirming(""), 4e3); ++ return () => clearTimeout(timer); ++ }, [confirming]); ++ const startNew = () => setDraft(blank()); ++ const edit = (item) => setDraft({ ++ ...blank(), ++ ...item, ++ originalName: item.name, ++ argsText: (item.args ?? []).join("\n"), ++ envText: JSON.stringify(item.env ?? {}, null, 2), ++ headersText: JSON.stringify(item.headers ?? {}, null, 2), ++ items: [...item.items ?? []] ++ }); ++ const remove = (item) => { ++ if (confirming !== item.name) { ++ setConfirming(item.name); ++ return; ++ } ++ setConfirming(""); ++ void run(actions.deleteCapability(item.name)); ++ }; ++ if (lockedWindow(roster)) { ++ return (0, import_react27.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react27.createElement)("h3", null, t("Shared capabilities")), ++ (0, import_react27.createElement)( ++ "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)( ++ "div", ++ { key: item.name, className: "bt-set-row" }, ++ (0, import_react27.createElement)( ++ "span", ++ { 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"))) ++ ) ++ )) ++ ) ++ ); ++ } + return (0, import_react27.createElement)( - "section", - { className: "bt-set-section" }, -- (0, import_react16.createElement)("h3", null, t("Shared capabilities")), -- (0, import_react16.createElement)( ++ "section", ++ { className: "bt-set-section" }, + (0, import_react27.createElement)("h3", null, t("Shared capabilities")), + (0, import_react27.createElement)( - "div", - { className: "bt-actions" }, -- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) ++ "div", ++ { className: "bt-actions" }, + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) ), -- draft ? (0, import_react16.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, -- (0, import_react16.createElement)( +- 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", - { className: "bt-set-card bt-connectors-card" }, -- items.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react16.createElement)(CapabilityRow, { +- { 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 ++ { 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, - item, - confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow - const attached = new Set(bot.capabilities ?? []); - const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; - if (items.length === 0) return null; -- return (0, import_react16.createElement)( ++ key: item.name, ++ item, ++ confirming: confirming === item.name, ++ onEdit: () => edit(item), ++ onDelete: () => remove(item), ++ onApprove: () => void run(actions.approveCapability(item.name)) ++ })) ++ ) ++ ); ++} ++function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { ++ const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name)); ++ const attached = new Set(bot.capabilities ?? []); ++ const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; ++ if (items.length === 0) return null; + return (0, import_react27.createElement)( - Card2, - { title: t("Capabilities") }, -- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { ++ Card2, ++ { title: t("Capabilities") }, + items.map((item) => (0, import_react27.createElement)(SwitchRow2, { - key: item.name, - label: item.name, - hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,49 +7372,84 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow ++ key: item.name, ++ label: item.name, ++ hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, ++ 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(", ") }); @@ -17679,48 +17416,50 @@ 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_react17 = require("react"); --var POLL_MS2 = 1500; ++// src/client/memory-panel.js +var import_react28 = require("react"); +var POLL_MS5 = 1500; - var MENU_EXIT_MS2 = 120; - var counts = /* @__PURE__ */ new Map(); ++var MENU_EXIT_MS2 = 120; ++var counts = /* @__PURE__ */ new Map(); +var failures = /* @__PURE__ */ new Map(); - var countListeners = /* @__PURE__ */ new Set(); ++var countListeners = /* @__PURE__ */ new Set(); +var notify = () => { + for (const listener of countListeners) listener(); +}; - function noteCount(botId, count) { -- if (counts.get(botId) === count) return; ++function noteCount(botId, count) { + const same2 = counts.get(botId) === count; + const recovered = failures.delete(botId); + if (same2 && !recovered) return; - counts.set(botId, count); -- for (const listener of countListeners) listener(); ++ counts.set(botId, count); + notify(); +} +function noteFailure(botId, reason) { + failures.set(botId, reason); + notify(); - } ++} +var countState = (botId) => ({ count: counts.get(botId) ?? null, failure: failures.get(botId) ?? null }); - function useMemoryCount(botId, actions) { -- const [count, setCount] = (0, import_react17.useState)(counts.get(botId) ?? null); -- (0, import_react17.useEffect)(() => { -- const listener = () => setCount(counts.get(botId) ?? null); ++function useMemoryCount(botId, actions) { + const [state, setState] = (0, import_react28.useState)(() => countState(botId)); + (0, import_react28.useEffect)(() => { + const listener = () => setState(countState(botId)); - countListeners.add(listener); - listener(); - let stop = false; -- actions.memoryView?.(botId).then((view) => { -- if (!stop) noteCount(botId, view.entries.length); -- }).catch(() => { -- }); ++ countListeners.add(listener); ++ listener(); ++ let stop = false; + const pending = actions.memoryView?.(botId); + if (pending) { + void Promise.resolve(pending).then((view) => { @@ -17730,311 +17469,548 @@ diff --git a/client.js b/client.js + }); + } return () => { - stop = true; - countListeners.delete(listener); +- stopped = true; +- clearTimeout(timer); +- listeners.delete(setView); ++ stop = true; ++ countListeners.delete(listener); }; - }, [botId]); -- return count; +- }, []); +- 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 MemoryRow({ bot, actions }) { -- const count = useMemoryCount(bot.id, actions); -- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); -- return (0, import_react17.createElement)( +-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 MemoryRow({ bot, actions }) { + const { count, failure } = useMemoryCount(bot.id, actions); + const label = memoryCountText({ count, failure }); + return (0, import_react28.createElement)( - "div", - null, -- (0, import_react17.createElement)("div", { className: "bt-section-title" }, t("Memory")), -- (0, import_react17.createElement)( ++ "div", ++ null, + (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Memory")), + (0, import_react28.createElement)( - "button", - { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, -- (0, import_react17.createElement)(MemoryIcon), -- (0, import_react17.createElement)("span", { className: "bt-mem-row-copy" }, label), -- (0, import_react17.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react17.createElement)(ChevronRightIcon)) -- ) ++ "button", ++ { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, + (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)) + ), + failure === null ? null : (0, import_react28.createElement)("div", { className: "bt-note", role: "status" }, label) - ); ++ ); } - function ago(at, now) { -@@ -7428,45 +7428,50 @@ function ago(at, now) { - return format.format(-Math.round(hours / 24), "day"); +-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; ++ const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" }); ++ const minutes = Math.round(seconds / 60); ++ if (minutes < 60) return format.format(-minutes, "minute"); ++ const hours = Math.round(minutes / 60); ++ if (hours < 24) return format.format(-hours, "hour"); ++ return format.format(-Math.round(hours / 24), "day"); } - function Skeleton({ label }) { -- return (0, import_react17.createElement)( +-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_react17.createElement)( ++ "div", ++ { className: "bt-mem-skel", "aria-busy": true }, + [0, 1].map((block) => (0, import_react28.createElement)( - "div", - { key: block, className: "bt-mem-skel-block" }, -- (0, import_react17.createElement)("span", { className: "bt-mem-skel-head" }), -- (0, import_react17.createElement)("span", null), -- (0, import_react17.createElement)("span", null), -- (0, import_react17.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) ++ "div", ++ { key: block, className: "bt-mem-skel-block" }, + (0, import_react28.createElement)("span", { className: "bt-mem-skel-head" }), + (0, import_react28.createElement)("span", null), + (0, import_react28.createElement)("span", null), + (0, import_react28.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) - )), -- label ? (0, import_react17.createElement)("div", { className: "bt-mem-skel-label" }, label) : null ++ )), + label ? (0, import_react28.createElement)("div", { className: "bt-mem-skel-label" }, label) : null - ); - } - function Summary({ bot, view, regenerate }) { - if (view.entries.length === 0) { -- return (0, import_react17.createElement)( ++ ); ++} ++function Summary({ bot, view, regenerate }) { ++ if (view.entries.length === 0) { + const notice = memoryEmptyNotice(view); + return (0, import_react28.createElement)( - "div", - { className: "bt-mem-empty" }, -- (0, import_react17.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react17.createElement)(MemoryIcon)), -- (0, import_react17.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), -- (0, import_react17.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 })) ++ "div", ++ { className: "bt-mem-empty" }, + (0, import_react28.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react28.createElement)(MemoryIcon)), + notice === null ? null : (0, import_react28.createElement)("div", { className: "bt-mem-empty-text", role: "status" }, notice), + (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_react17.createElement)( + const failed = view.error && !view.summarizing ? (0, import_react28.createElement)( - "div", - { className: "bt-mem-failed", role: "alert" }, -- (0, import_react17.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_react17.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) ++ "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; -- if (!view.summary) return (0, import_react17.createElement)(import_react17.Fragment, null, failed, failed ? null : (0, import_react17.createElement)(Skeleton, { label: t("Writing the summary…") })); -- return (0, import_react17.createElement)( -- import_react17.Fragment, ++ ) : 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, ++ null, + blocked, - failed, -- (0, import_react17.createElement)( ++ 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_react17.createElement)("h3", { key: index }, block.text) : (0, import_react17.createElement)("p", { key: index }, block.text)) ++ "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)) - ) - ); ++ ) ++ ); } -@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { - return groups; - }, []); - function EntryRow({ entry, remove }) { -- const [confirming, setConfirming] = (0, import_react17.useState)(false); -- const [busy, setBusy] = (0, import_react17.useState)(false); -- (0, import_react17.useEffect)(() => { +-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); -@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { + }, [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); - if (!await remove(entry)) setBusy(false); +- void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); ++ if (!await remove(entry)) setBusy(false); }; -- return (0, import_react17.createElement)( +- 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_react17.createElement)( ++ "li", ++ { className: "bt-mem-entry", "data-busy": busy || void 0 }, + (0, import_react28.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 ++ "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_react17.createElement)("button", { +- (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-mem-remove", +- className: "bt-mini bt-task-remove", ++ className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,91 +7512,93 @@ function EntryRow({ entry, remove }) { - "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), - title: confirming ? void 0 : t("Remove from memory"), - onClick: click -- }, confirming ? t("Remove") : (0, import_react17.createElement)(TrashIcon)) + 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 Entries({ bot, view, remove }) { - const groups = groupEntries(view.entries); +-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_react17.createElement)( ++ 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-mem-entries" }, -- sections.map((section) => (0, import_react17.createElement)( +- { 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")) +- ); +-} +-function SchedulesPage({ roster, actions, start }) { +- const view = useSchedules(actions); +- const [adding, setAdding] = (0, import_react15.useState)(Boolean(start)); +- if (view === null) return (0, import_react15.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); +- if (!view.available) return (0, import_react15.createElement)(AutomationOff); +- const shown = (next) => { +- publish(next); +- setAdding(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)]) +- ].filter(([, , , tasks]) => tasks.length > 0); +- 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", - { key: section.scope, className: "bt-mem-section" }, -- (0, import_react17.createElement)( +- { 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_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", ++ { 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_react17.createElement)( -- import_react17.Fragment, ++ ), + 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_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")) ++ { 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 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 BotSchedules({ bot, roster, actions }) { +- const view = useSchedules(actions); +- if (view === null) return null; +- const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; +- return (0, import_react15.createElement)( +- "section", +- { 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_react17.createElement)( ++} ++function Reply({ answer, dismiss }) { + return (0, import_react28.createElement)( - "div", - { className: "bt-mem-reply", role: "status" }, -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem-reply", role: "status" }, + (0, import_react28.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)) +- { className: "bt-set-card" }, +- !view.available ? (0, import_react15.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (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")) +- ) : [ +- ...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)) - ), -- 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, ++ ), + 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_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)( ++ 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_react17.createElement)(CheckIcon) : (0, import_react17.createElement)(CloseIcon), -- (0, import_react17.createElement)("span", null, changeLine(change)) ++ "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 ++ ))) : null ) ); } - function useMemoryView(botId, actions) { -- const [view, setView] = (0, import_react17.useState)(null); -- const [error, setError] = (0, import_react17.useState)(""); -- const timer = (0, import_react17.useRef)(0); -- const latest = (0, import_react17.useRef)(0); -- const alive = (0, import_react17.useRef)(true); -- const show = (0, import_react17.useCallback)((next) => { +-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", +- 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)( +- "button", +- { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, +- (0, import_react15.createElement)(ClockIcon), +- (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_react17.useCallback)(async (flags = {}) => { ++ 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_MS2); ++ 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_react17.useEffect)(() => { ++ } 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 () => { -@@ -7606,38 +7606,333 @@ function useMemoryView(botId, actions) { - }, [botId]); - return { view, error, load, show }; ++ alive.current = true; ++ void load({ summarize: true }); ++ return () => { ++ alive.current = false; ++ clearTimeout(timer.current); ++ }; ++ }, [botId]); ++ return { view, error, load, show }; } --function useNow(every) { -- const [now, setNow] = (0, import_react17.useState)(Date.now()); -- (0, import_react17.useEffect)(() => { +-var SCHEDULE_CSS = ` +-.bt-task{gap:10px} +-.bt-task[data-done]{opacity:.6} +-.bt-task[data-busy]{opacity:.4} +-.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} +-.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +-.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} +-.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} +-.bt-task-remove svg{width:14px;height:14px} +-.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} +-.bt-task-head{display:flex;align-items:center;gap:6px} +-.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} +-.bt-task-bot{width:auto;min-width:160px;max-width:58%} +-.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px} +-.bt-task-when .bt-input{width:auto;height:32px} +-.bt-task-time{min-width:110px} +-.bt-task-count{width:72px!important} +-.bt-task-unit{width:auto;height:32px} +-.bt-task-every{font-size:13px;color:var(--bt-ink-2)} +-.bt-task-days{display:flex;gap:4px} +-.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)} +-.bt-task-foot{justify-content:flex-end;gap:8px} +-.bt-task-foot .bt-send{height:28px;font-size:12px} +-.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center} +-.bt-task-off>*+*{border-top:0} +-.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +-.bt-task-off-mark svg{width:20px;height:20px} +-.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600} +-.bt-task-off .bt-set-hint{max-width:380px} +-.bt-task-list{display:flex;flex-direction:column;gap:2px} +-.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease} +-.bt-task-line:hover{background:var(--bt-hover)} +-.bt-task-line svg{flex:none;color:var(--bt-ink-3)} +-.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)} +-@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}} +-`; +- +-// src/client/capability-settings.js +-var import_react16 = require("react"); +-var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); +-var parseMap = (text, label) => { +- let value; +- try { +- value = JSON.parse(text || "{}"); +- } catch { +- throw new Error(t("{label} must be valid JSON.", { label })); +- } +- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label })); +- return value; +-}; +-var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); +-function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { +- return (0, import_react16.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t(label))), +- (0, import_react16.createElement)(multiline ? "textarea" : "input", { +- className: multiline ? "bt-input bt-input-area" : "bt-input", +- value, +- disabled, +- maxLength: label === "Name" ? 32 : void 0, +- placeholder, +- 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; ++ const id = setInterval(() => setNow(Date.now()), every); ++ return () => clearInterval(id); ++ }, [every]); ++ return now; } --function MemoryDialog({ bot, actions, leaving = false }) { -- const { view, error, load, show } = useMemoryView(bot.id, actions); -- const [page, setPage] = (0, import_react17.useState)("summary"); -- const [menu, setMenu] = (0, import_react17.useState)(false); -- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); -- const [text, setText] = (0, import_react17.useState)(""); -- const [answer, setAnswer] = (0, import_react17.useState)(null); -- const menuRef = (0, import_react17.useRef)(null); -- const dotsRef = (0, import_react17.useRef)(null); -- const inputRef = (0, import_react17.useRef)(null); -- const now = useNow(3e4); -- const close = (0, import_react17.useCallback)(() => actions.closeMemory(), []); -- useEscape(close); -- (0, import_react17.useEffect)(() => { -- if (!menu) return void 0; +-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(""); +- let payload; +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() !== ""; @@ -18083,13 +18059,27 @@ diff --git a/client.js b/client.js + void load({ summarize: true }); + }; + const remove = async (entry) => { -+ try { + try { +- payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description }; +- if (draft.kind === "mcp" && draft.transport === "stdio") { +- payload.transport = "stdio"; +- payload.command = draft.command; +- payload.args = draft.argsText.split("\n").filter((line) => line !== ""); +- payload.env = parseMap(draft.envText, t("Environment")); +- } else if (draft.kind === "mcp") { +- payload.transport = "streamable-http"; +- payload.url = draft.url; +- payload.headers = parseMap(draft.headersText, t("Headers")); +- } else if (draft.kind === "skill") payload.content = draft.content; +- else payload.items = draft.items; + show(await actions.memoryForget(bot.id, entry)); + return true; -+ } catch (failure) { + } catch (failure) { +- setError(failure?.message ?? String(failure)); +- return; + setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) }); + return false; -+ } + } + }; + const ask = async (event) => { + event.preventDefault(); @@ -18097,16 +18087,72 @@ diff --git a/client.js b/client.js + if (asked === "" || answer?.pending) return; + setText(""); + setAnswer({ asked, pending: true }); -+ try { + try { +- await actions.saveCapability(payload); +- onClose(); + const result = await actions.memoryAsk(bot.id, asked); + setAnswer({ asked, reply: result.reply, changes: result.changes }); + show(result.view); + if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" }); -+ } catch (failure) { + } catch (failure) { +- setError(failure?.message ?? String(failure)); + setAnswer({ asked, error: failure?.message ?? String(failure) }); -+ } + } + inputRef.current?.focus(); -+ }; + }; +- const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); +- return (0, import_react16.createElement)( +- "form", +- { className: "bt-connector-form", onSubmit: submit }, +- (0, import_react16.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), +- (0, import_react16.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Type"))), +- (0, import_react16.createElement)( +- "select", +- { 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_react16.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) +- ) +- ), +- (0, import_react16.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), +- draft.kind === "mcp" ? (0, import_react16.createElement)( +- "label", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), +- (0, import_react16.createElement)( +- "select", +- { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) }, +- (0, import_react16.createElement)("option", { value: "stdio" }, t("Local command")), +- (0, import_react16.createElement)("option", { value: "streamable-http" }, t("HTTP server")) +- ) +- ) : null, +- draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react16.createElement)( +- "div", +- null, +- (0, import_react16.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), +- (0, import_react16.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), +- (0, import_react16.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) +- ) : null, +- draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react16.createElement)( +- "div", +- null, +- (0, import_react16.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), +- (0, import_react16.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) +- ) : null, +- draft.kind === "skill" ? (0, import_react16.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, +- draft.kind === "pack" ? (0, import_react16.createElement)( +- "div", +- { className: "bt-set-card" }, +- available.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react16.createElement)( +- "label", +- { key: item.name, className: "bt-set-row bt-bs-row" }, +- (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, item.name)), +- (0, import_react16.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) +- )) +- ) : null, +- error ? (0, import_react16.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, +- (0, import_react16.createElement)( + const count = view?.entries.length ?? 0; + const when3 = view?.summary ? ago(view.summary.at, now) : null; + const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; @@ -18116,7 +18162,10 @@ diff --git a/client.js b/client.js + { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, + (0, import_react28.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-connector-form-row" }, +- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), +- (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) + { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, + (0, import_react28.createElement)( + "div", @@ -18174,9 +18223,16 @@ diff --git a/client.js b/client.js + ) + ) + ) -+ ) -+ ); -+} + ) + ); + } +-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_react16.createElement)( +- "div", +- { className: "bt-connector" }, +- (0, import_react16.createElement)( + +// src/client/standing-approvals.js +var import_react29 = require("react"); @@ -18192,14 +18248,27 @@ diff --git a/client.js b/client.js + Card2, + { title: t("Always allowed") }, + grants.map((grant) => (0, import_react29.createElement)( -+ "div", + "div", +- { className: "bt-connector-head" }, +- (0, import_react16.createElement)( + { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind }, + (0, import_react29.createElement)( -+ "span", + "span", +- { className: "bt-connector-text" }, +- (0, import_react16.createElement)("span", { className: "bt-connector-name" }, item.name), +- (0, import_react16.createElement)("span", { className: "bt-connector-status" }, secondary) + { className: "bt-set-copy" }, + (0, import_react29.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind), + (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) -+ ), + ), +- (0, import_react16.createElement)( +- "span", +- { className: "bt-connector-actions" }, +- item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, +- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), +- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) +- ) +- ) + (0, import_react29.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", @@ -18207,8 +18276,30 @@ diff --git a/client.js b/client.js + onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind)) + }, t("Remove")) + )) -+ ); -+} + ); + } +-function CapabilitiesPage({ roster, actions, run }) { +- const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name)); +- const [draft, setDraft] = (0, import_react16.useState)(null); +- const [confirming, setConfirming] = (0, import_react16.useState)(""); +- (0, import_react16.useEffect)(() => { +- if (!confirming) return void 0; +- const timer = setTimeout(() => setConfirming(""), 4e3); +- return () => clearTimeout(timer); +- }, [confirming]); +- const startNew = () => setDraft(blank()); +- const edit = (item) => setDraft({ +- ...blank(), +- ...item, +- originalName: item.name, +- argsText: (item.args ?? []).join("\n"), +- envText: JSON.stringify(item.env ?? {}, null, 2), +- headersText: JSON.stringify(item.headers ?? {}, null, 2), +- items: [...item.items ?? []] +- }); +- const remove = (item) => { +- if (confirming !== item.name) { +- setConfirming(item.name); + +// src/client/bot-settings.js +var import_react30 = require("react"); @@ -18242,11 +18333,52 @@ diff --git a/client.js b/client.js + setDraft(null); + if (reverting.current) { + reverting.current = false; -+ return; -+ } + return; + } +- setConfirming(""); +- void run(actions.deleteCapability(item.name)); + if (next === void 0 || next === (value ?? "") || required && next === "") return; + void onSave(next); -+ }; + }; +- return (0, import_react16.createElement)( +- "section", +- { className: "bt-set-section" }, +- (0, import_react16.createElement)("h3", null, t("Shared capabilities")), +- (0, import_react16.createElement)( +- "div", +- { className: "bt-actions" }, +- (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) +- ), +- draft ? (0, import_react16.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, +- (0, import_react16.createElement)( +- "div", +- { className: "bt-set-card bt-connectors-card" }, +- items.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react16.createElement)(CapabilityRow, { +- key: item.name, +- item, +- confirming: confirming === item.name, +- onEdit: () => edit(item), +- onDelete: () => remove(item), +- onApprove: () => void run(actions.approveCapability(item.name)) +- })) +- ) +- ); +-} +-function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { +- const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name)); +- const attached = new Set(bot.capabilities ?? []); +- const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; +- if (items.length === 0) return null; +- return (0, import_react16.createElement)( +- Card2, +- { title: t("Capabilities") }, +- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { +- key: item.name, +- label: item.name, +- hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, +- on: attached.has(item.name), +- onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled)) +- })) + return (0, import_react30.createElement)(multiline ? "textarea" : "input", { + className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", + value: draft ?? value ?? "", @@ -18273,33 +18405,97 @@ diff --git a/client.js b/client.js + { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null), + children -+ ); -+} + ); + } +- +-// src/client/memory-panel.js +-var import_react17 = require("react"); +-var POLL_MS2 = 1500; +-var MENU_EXIT_MS2 = 120; +-var counts = /* @__PURE__ */ new Map(); +-var countListeners = /* @__PURE__ */ new Set(); +-function noteCount(botId, count) { +- if (counts.get(botId) === count) return; +- counts.set(botId, count); +- for (const listener of countListeners) listener(); +function Card({ title, children }) { + return (0, import_react30.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react30.createElement)("h3", null, title) : null, (0, import_react30.createElement)("div", { className: "bt-set-card" }, children)); -+} + } +-function useMemoryCount(botId, actions) { +- const [count, setCount] = (0, import_react17.useState)(counts.get(botId) ?? null); +- (0, import_react17.useEffect)(() => { +- const listener = () => setCount(counts.get(botId) ?? null); +- countListeners.add(listener); +- listener(); +- let stop = false; +- actions.memoryView?.(botId).then((view) => { +- if (!stop) noteCount(botId, view.entries.length); +- }).catch(() => { +- }); +- return () => { +- stop = true; +- countListeners.delete(listener); +- }; +- }, [botId]); +- return count; +function Switch({ on, label, disabled, onChange }) { + return (0, import_react30.createElement)( + "button", + { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, + (0, import_react30.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) + ); -+} + } +-function MemoryRow({ bot, actions }) { +- const count = useMemoryCount(bot.id, actions); +- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); +- return (0, import_react17.createElement)( +function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) { + return (0, import_react30.createElement)( -+ "div", + "div", +- null, +- (0, import_react17.createElement)("div", { className: "bt-section-title" }, t("Memory")), +- (0, import_react17.createElement)( +- "button", +- { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, +- (0, import_react17.createElement)(MemoryIcon), +- (0, import_react17.createElement)("span", { className: "bt-mem-row-copy" }, label), +- (0, import_react17.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react17.createElement)(ChevronRightIcon)) +- ) + { className: "bt-set-row bt-bs-row" }, + (0, import_react30.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react30.createElement)(Switch, { on, label, disabled, onChange }) -+ ); -+} + ); + } +-function ago(at, now) { +- const seconds = Math.max(0, Math.round((now - at) / 1e3)); +- if (seconds < 60) return null; +- const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" }); +- const minutes = Math.round(seconds / 60); +- if (minutes < 60) return format.format(-minutes, "minute"); +- const hours = Math.round(minutes / 60); +- if (hours < 24) return format.format(-hours, "hour"); +- return format.format(-Math.round(hours / 24), "day"); +function modelHint(bot, roster) { + const shown = modelOf(bot, roster); + if (shown === void 0) return t("Pick the model this Bot runs on."); + if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one."); + if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it."); + return t("Applies from the next message."); -+} + } +-function Skeleton({ label }) { +- return (0, import_react17.createElement)( +- "div", +- { className: "bt-mem-skel", "aria-busy": true }, +- [0, 1].map((block) => (0, import_react17.createElement)( +- "div", +- { key: block, className: "bt-mem-skel-block" }, +- (0, import_react17.createElement)("span", { className: "bt-mem-skel-head" }), +- (0, import_react17.createElement)("span", null), +- (0, import_react17.createElement)("span", null), +- (0, import_react17.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) +- )), +- label ? (0, import_react17.createElement)("div", { className: "bt-mem-skel-label" }, label) : null +- ); +function ShapeRing({ shape, size }) { + const maskId = (0, import_react30.useRef)(""); + if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`; @@ -18307,7 +18503,26 @@ diff --git a/client.js b/client.js + const unit = def.size / size; + const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`; + return (0, import_react30.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); -+} + } +-function Summary({ bot, view, regenerate }) { +- if (view.entries.length === 0) { +- return (0, import_react17.createElement)( +- "div", +- { className: "bt-mem-empty" }, +- (0, import_react17.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react17.createElement)(MemoryIcon)), +- (0, import_react17.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), +- (0, import_react17.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_react17.createElement)( +- "div", +- { className: "bt-mem-failed", role: "alert" }, +- (0, import_react17.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_react17.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) +- ) : null; +- if (!view.summary) return (0, import_react17.createElement)(import_react17.Fragment, null, failed, failed ? null : (0, import_react17.createElement)(Skeleton, { label: t("Writing the summary…") })); +- return (0, import_react17.createElement)( +- import_react17.Fragment, +function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { + const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))]; + if (!shapes.includes(look.shape)) shapes.push(look.shape); @@ -18315,10 +18530,60 @@ diff --git a/client.js b/client.js + for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS)); + return (0, import_react30.createElement)( + import_react30.Fragment, -+ null, + null, +- failed, +- (0, import_react17.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_react17.createElement)("h3", { key: index }, block.text) : (0, import_react17.createElement)("p", { key: index }, block.text)) +- ) +- ); +-} +-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_react17.useState)(false); +- const [busy, setBusy] = (0, import_react17.useState)(false); +- (0, import_react17.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); +- }; +- 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", + 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), @@ -18334,8 +18599,15 @@ diff --git a/client.js b/client.js + "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"); @@ -18346,63 +18618,22 @@ diff --git a/client.js b/client.js + if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); + }; + (0, import_react30.useEffect)(() => { - const onDown = (event) => { -- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -- setMenu(false); ++ const onDown = (event) => { + if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; + onClose(); - }; - const onKey = (event) => { - if (event.key === "Escape") { - event.stopPropagation(); -- setMenu(false); -- dotsRef.current?.focus(); ++ }; ++ const onKey = (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); + onClose(); - } - }; - window.addEventListener("mousedown", onDown); -@@ -7646,514 +7646,917 @@ function MemoryDialog({ bot, actions, leaving = false }) { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); - }; -- }, [menu]); -- const regenerate = () => { -- setMenu(false); -- setPage("summary"); -- void load({ regenerate: true }); -- }; -- const openEntries = () => { -- setMenu(false); -- setPage("entries"); -- }; -- const backToSummary = () => { -- setPage("summary"); -- void load({ summarize: true }); -- }; -- const remove = async (entry) => { -- try { -- show(await actions.memoryForget(bot.id, entry)); -- return true; -- } catch (failure) { -- setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) }); -- return false; -- } -- }; -- const ask = async (event) => { -- event.preventDefault(); -- const asked = text.trim(); -- if (asked === "" || answer?.pending) return; -- setText(""); -- setAnswer({ asked, pending: true }); -- try { -- const result = await actions.memoryAsk(bot.id, asked); -- setAnswer({ asked, reply: result.reply, changes: result.changes }); -- show(result.view); -- if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" }); -- } catch (failure) { -- setAnswer({ asked, error: failure?.message ?? String(failure) }); -- } -- inputRef.current?.focus(); ++ } ++ }; ++ window.addEventListener("mousedown", onDown); ++ window.addEventListener("keydown", onKey, true); ++ return () => { ++ window.removeEventListener("mousedown", onDown); ++ window.removeEventListener("keydown", onKey, true); ++ }; + }, [onClose]); + (0, import_react30.useEffect)(() => { + if (tab !== "upload") return void 0; @@ -18419,24 +18650,69 @@ diff --git a/client.js b/client.js + const hasPhoto = Boolean(look.image); + const glide = useGlide(tab); + return (0, import_react30.createElement)( -+ "div", + "div", +- { className: "bt-mem-entries" }, +- sections.map((section) => (0, import_react17.createElement)( +- "section", +- { key: section.scope, className: "bt-mem-section" }, +- (0, import_react17.createElement)( + { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, + (0, import_react30.createElement)( + "div", + { className: "bt-avedit-top" }, + (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()) })) + { 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", + title: t("Reset character to default"), + 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 +- ) + tab === "bot" ? (0, import_react30.createElement)(CharacterGrid, { + look, + hasPhoto, @@ -18473,8 +18749,48 @@ diff --git a/client.js b/client.js + upload(file); + } }) + ) : null -+ ); -+} + ); + } +-function useMemoryView(botId, actions) { +- const [view, setView] = (0, import_react17.useState)(null); +- const [error, setError] = (0, import_react17.useState)(""); +- const timer = (0, import_react17.useRef)(0); +- const latest = (0, import_react17.useRef)(0); +- const alive = (0, import_react17.useRef)(true); +- const show = (0, import_react17.useCallback)((next) => { +- setView(next); +- setError(""); +- noteCount(botId, next.entries.length); +- }, [botId]); +- const load = (0, import_react17.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_MS2); +- } catch (failure) { +- if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure)); +- } +- }, [botId]); +- (0, import_react17.useEffect)(() => { +- alive.current = true; +- void load({ summarize: true }); +- return () => { +- alive.current = false; +- clearTimeout(timer.current); +- }; +- }, [botId]); +- return { view, error, load, show }; +-} +-function useNow(every) { +- const [now, setNow] = (0, import_react17.useState)(Date.now()); +- (0, import_react17.useEffect)(() => { +- const id = setInterval(() => setNow(Date.now()), every); +- return () => clearInterval(id); +- }, [every]); +- return now; +function MemoryCard({ bot, actions }) { + const { count, failure } = useMemoryCount(bot.id, actions); + const label = memoryCountText({ count, failure }); @@ -18488,7 +18804,77 @@ diff --git a/client.js b/client.js + (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"); +- const [menu, setMenu] = (0, import_react17.useState)(false); +- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); +- const [text, setText] = (0, import_react17.useState)(""); +- const [answer, setAnswer] = (0, import_react17.useState)(null); +- const menuRef = (0, import_react17.useRef)(null); +- const dotsRef = (0, import_react17.useRef)(null); +- const inputRef = (0, import_react17.useRef)(null); +- const now = useNow(3e4); +- const close = (0, import_react17.useCallback)(() => actions.closeMemory(), []); +- useEscape(close); +- (0, import_react17.useEffect)(() => { +- if (!menu) return void 0; +- const onDown = (event) => { +- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; +- setMenu(false); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- setMenu(false); +- dotsRef.current?.focus(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [menu]); +- const regenerate = () => { +- setMenu(false); +- setPage("summary"); +- void load({ regenerate: true }); +- }; +- const openEntries = () => { +- setMenu(false); +- setPage("entries"); +- }; +- const backToSummary = () => { +- setPage("summary"); +- void load({ summarize: true }); +- }; +- const remove = async (entry) => { +- try { +- show(await actions.memoryForget(bot.id, entry)); +- return true; +- } catch (failure) { +- setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) }); +- return false; +- } +- }; +- const ask = async (event) => { +- event.preventDefault(); +- const asked = text.trim(); +- if (asked === "" || answer?.pending) return; +- setText(""); +- setAnswer({ asked, pending: true }); +- try { +- const result = await actions.memoryAsk(bot.id, asked); +- setAnswer({ asked, reply: result.reply, changes: result.changes }); +- show(result.view); +- if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" }); +- } catch (failure) { +- setAnswer({ asked, error: failure?.message ?? String(failure) }); +- } +- inputRef.current?.focus(); +function BotSettingsForm({ bot, roster, actions, run }) { + const [editing, setEditing] = (0, import_react30.useState)(false); + const [confirming, setConfirming] = (0, import_react30.useState)(false); @@ -18502,6 +18888,43 @@ diff --git a/client.js b/client.js + const isMainBot = isMainOf(roster, bot.id); + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; + const isTile = roster.mainBotId === bot.id; ++ if (lockedWindow(roster)) { ++ return (0, import_react30.createElement)( ++ "div", ++ { className: "bt-bs" }, ++ (0, import_react30.createElement)( ++ "div", ++ { className: "bt-bs-hero" }, ++ (0, import_react30.createElement)(BotMark, { bot, size: 64, live: false }), ++ (0, import_react30.createElement)( ++ "div", ++ { className: "bt-bs-who" }, ++ (0, import_react30.createElement)("span", { className: "bt-bs-name" }, bot.name), ++ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) ++ ) ++ ), ++ (0, import_react30.createElement)( ++ Card, ++ { title: t("Profile") }, ++ (0, import_react30.createElement)(Row, { label: t("Label"), hint: bot.role || t("Bot") }) ++ ), ++ (0, import_react30.createElement)( ++ Card, ++ { title: t("Model") }, ++ (0, import_react30.createElement)(Row, { label: t("Model"), hint: modelOf(bot, roster)?.name ?? modelHint(bot, roster) }) ++ ), ++ (0, import_react30.createElement)( ++ Card, ++ { title: t("Working folder") }, ++ (0, import_react30.createElement)(Row, { label: t("Folder"), hint: bot.workDir || t("Not set") }) ++ ), ++ (0, import_react30.createElement)( ++ Card, ++ { title: t("Instructions") }, ++ (0, import_react30.createElement)(Row, { label: t("What this Bot does"), hint: bot.instructions || t("None"), stack: true }) ++ ) ++ ); ++ } + const save = (patch) => run(actions.updateBot(bot.id, patch)); + const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); + const remove = () => { @@ -19000,6 +19423,37 @@ diff --git a/client.js b/client.js + const admin = roster.byId[room2.admin]; + const mode = shownMode(room2, roster); + const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; ++ if (lockedWindow(roster)) { ++ return (0, import_react31.createElement)( ++ "div", ++ { className: "bt-bs" }, ++ (0, import_react31.createElement)( ++ "div", ++ { className: "bt-bs-hero" }, ++ (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), ++ (0, import_react31.createElement)( ++ "div", ++ { className: "bt-bs-who" }, ++ (0, import_react31.createElement)("span", { className: "bt-bs-name" }, room2.name), ++ (0, import_react31.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) ++ ) ++ ), ++ (0, import_react31.createElement)( ++ Card, ++ { title: t("Members · {count}", { count: face ? people2.length : members.length + humans.length + remotes.length }) }, ++ face ? people2.map((person) => (0, import_react31.createElement)(Row, { key: person.key, label: facePersonLabel(person), hint: t("Human") })) : [ ++ humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: person.displayName || person.id, hint: t("Human") })), ++ members.map((member) => (0, import_react31.createElement)(Row, { ++ key: member.id, ++ label: member.name, ++ hint: [room2.admin === member.id ? t("Admin") : "", room2.taskLead === member.id ? t("Task lead") : ""].filter(Boolean).join(" · ") || t("Bot") ++ })), ++ remotes.map((person) => (0, import_react31.createElement)(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") })) ++ ] ++ ), ++ room2.notice ? (0, import_react31.createElement)(Card, { title: t("Notice") }, (0, import_react31.createElement)(Row, { label: t("Group notice"), hint: room2.notice, stack: true })) : null ++ ); ++ } + const update = (patch) => run(actions.updateRoom(room2.id, patch)); + const openBot = (id) => { + actions.closeSettings(); @@ -20613,7 +21067,7 @@ diff --git a/client.js b/client.js ) ); } - +- -// src/client/group-settings.js -var import_react23 = require("react"); -var ROOM_MODES = [ @@ -20624,6 +21078,7 @@ 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; @@ -20665,24 +21120,7 @@ diff --git a/client.js b/client.js + const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs); + Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null })); + }); - } --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 moveNode(nodes, id, x, y) { + return nodes.map((node) => node.id === id ? { ...node, x, y } : node); +} @@ -20701,14 +21139,7 @@ diff --git a/client.js b/client.js + return { + nodes: nodes.filter((node) => node.id !== id), + edges: edges.filter((edge) => edge.from !== id && edge.to !== id) - }; -- const remove = () => { -- if (!confirming) { -- setConfirming(true); -- return; -- } -- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => { -- if (done) onDeleted?.(); ++ }; +} +function chainOf(item) { + if (typeof item?.chain === "string" && item.chain) return item.chain; @@ -20773,7 +21204,24 @@ 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)); @@ -20837,7 +21285,7 @@ diff --git a/client.js b/client.js + key: `${item.from.id}>${item.to.id}`, + back: false, + d: straight ? `M ${x1} ${y1} L ${x1} ${y2}` : `M ${x1} ${y1} L ${x1} ${yA} L ${lane} ${yA} L ${lane} ${yB} L ${x2} ${yB} L ${x2} ${y2}` - }); ++ }); + } + return routes; +} @@ -20880,7 +21328,10 @@ diff --git a/client.js b/client.js + right: centre + reach * scale, + scrollWidth: Math.max(width + pad2, centre + reach * scale) }; -- return (0, import_react23.createElement)( +- const remove = () => { +- if (!confirming) { +- setConfirming(true); +- return; +} +function measureCanvasWidth(el) { + const width = el?.clientWidth; @@ -20949,8 +21400,12 @@ diff --git a/client.js b/client.js + const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX / scale) : 24; + const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY / scale) : 24; + onCommit?.({ nodes: dropBot(nodes, current.bot, x, y), edges }); -+ } -+ }; + } +- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => { +- if (done) onDeleted?.(); +- }); + }; +- return (0, import_react23.createElement)( + return (0, import_react37.createElement)( "div", - { className: "bt-bs" }, @@ -22560,7 +23015,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +10184,203 @@ function ConnectorRow({ connector, actions }) { +@@ -10184,83 +10184,219 @@ function ConnectorRow({ connector, actions }) { after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -22757,6 +23212,22 @@ diff --git a/client.js b/client.js function ConnectorsPage({ roster, actions }) { const list = roster?.connectors ?? []; - return (0, import_react25.createElement)( ++ if (lockedWindow(roster)) { ++ return (0, import_react39.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react39.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react39.createElement)("div", { className: "bt-set-row" }, (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react39.createElement)( ++ "div", ++ { key: connector.id, className: "bt-set-row" }, ++ (0, import_react39.createElement)( ++ "span", ++ { className: "bt-set-copy" }, ++ (0, import_react39.createElement)("span", { className: "bt-bs-label" }, connector.name), ++ (0, import_react39.createElement)("span", { className: "bt-set-hint" }, statusText2(connector)) ++ ) ++ ))) ++ ); ++ } + return (0, import_react39.createElement)( "section", { className: "bt-set-section" }, @@ -23174,7 +23645,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,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +10569,76 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -23220,6 +23691,23 @@ diff --git a/client.js b/client.js clearTimeout(timer.current); timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350); }; ++ if (lockedWindow(roster)) { ++ const accent = ACCENT_INKS.find((ink) => INKS[ink][0] === prefs.accent); ++ return [ ++ (0, import_react42.createElement)( ++ SetSection, ++ { key: "look", title: t("Appearance") }, ++ (0, import_react42.createElement)(SetRow, { label: t("Theme"), hint: t(themes[current].label) }), ++ (0, import_react42.createElement)(SetRow, { label: t("Accent"), hint: accent ? t(capitalize2(accent)) : prefs.accent ?? t("Default") }) ++ ), ++ (0, import_react42.createElement)( ++ SetSection, ++ { key: "notify", title: t("Notifications") }, ++ (0, import_react42.createElement)(SetRow, { label: t("In other chats"), hint: prefs.notifications !== false ? t("On") : t("Off") }), ++ NOTIFY_KINDS.map(([kind, label]) => (0, import_react42.createElement)(SetRow, { key: kind, label: t(label), hint: prefs.notifications !== false && prefs.notify?.[kind] !== false ? t("On") : t("Off") })) ++ ) ++ ]; ++ } return [ - (0, import_react28.createElement)( + (0, import_react42.createElement)( @@ -23252,7 +23740,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,86 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,101 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -23301,6 +23789,14 @@ diff --git a/client.js b/client.js - return (0, import_react28.createElement)( + const own = roster.bots.filter((bot) => !bot.remote); + const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); ++ if (lockedWindow(roster)) { ++ return (0, import_react42.createElement)( ++ SetSection, ++ { title: t("Main Bot") }, ++ (0, import_react42.createElement)(SetRow, { label: t("Main Bot"), hint: first ? first.name : void 0 }), ++ others.map((bot) => (0, import_react42.createElement)(SetRow, { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") })) ++ ); ++ } + return (0, import_react42.createElement)( SetSection, { title: t("Main Bot") }, @@ -23350,6 +23846,13 @@ diff --git a/client.js b/client.js function MotionSection({ roster, actions, run }) { const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal"; - return (0, import_react28.createElement)( ++ if (lockedWindow(roster)) { ++ return (0, import_react42.createElement)( ++ SetSection, ++ { title: t("Animation") }, ++ (0, import_react42.createElement)(SetRow, { label: t("Bot animation"), hint: t(MOTIONS.find(([id]) => id === motion)?.[1] ?? "Normal") }) ++ ); ++ } + return (0, import_react42.createElement)( SetSection, { title: t("Animation") }, @@ -23795,14 +24298,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)] ], @@ -23943,11 +24446,10 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11005,277 +11005,41 @@ var migrateBrowsers = (storage) => { - const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => { +@@ -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; @@ -24215,7 +24717,6 @@ 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; diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index c015dd4a4..40de66b51 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -490,7 +490,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js diff --git a/src/client/group-settings.js b/src/client/group-settings.js --- a/src/client/group-settings.js +++ b/src/client/group-settings.js -@@ -1,11 +1,15 @@ +@@ -1,11 +1,16 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' @@ -499,6 +499,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js +import { setLocalBots } from './characters.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' ++import { lockedWindow } from './schedules.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' +import { StandingApprovalsCard } from './standing-approvals.js' @@ -645,7 +646,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js } // A group chat's settings as grouped cards: its name and notice, who answers, its -@@ -31,8 +31,22 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,11 +31,48 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -669,6 +670,32 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' ++ // Every control here writes `update-room` or `delete-room`, which the host refuses in a ++ // read-only window; the form then keeps the group's facts as text (#2474). ++ if (lockedWindow(roster)) { ++ return h('div', { className: 'bt-bs' }, ++ h('div', { className: 'bt-bs-hero' }, ++ h(RoomAvatar, { room, roster, size: 64 }), ++ h('div', { className: 'bt-bs-who' }, ++ h('span', { className: 'bt-bs-name' }, room.name), ++ h('span', { className: 'bt-bs-sub' }, roomSubtitle(room, roster)))), ++ h(Card, { title: t('Members · {count}', { count: face ? people.length : members.length + humans.length + remotes.length }) }, ++ face ++ ? people.map(person => h(Row, { key: person.key, label: facePersonLabel(person), hint: t('Human') })) ++ : [ ++ humans.map(person => h(Row, { key: person.id, label: person.displayName || person.id, hint: t('Human') })), ++ members.map(member => h(Row, { ++ key: member.id, ++ label: member.name, ++ hint: [room.admin === member.id ? t('Admin') : '', room.taskLead === member.id ? t('Task lead') : ''].filter(Boolean).join(' · ') || t('Bot'), ++ })), ++ remotes.map(person => h(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") })), ++ ]), ++ room.notice ? h(Card, { title: t('Notice') }, h(Row, { label: t('Group notice'), hint: room.notice, stack: true })) : null) ++ } + const update = patch => run(actions.updateRoom(room.id, patch)) + const openBot = (id) => { actions.closeSettings(); actions.openSession(id) } + const remove = () => { @@ -65,37 +65,79 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 775a5ea43..5bebe570e 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -179,6 +179,14 @@ diff --git a/src/client/diagnostics.js b/src/client/diagnostics.js diff --git a/src/client/secret-card.js b/src/client/secret-card.js --- a/src/client/secret-card.js +++ b/src/client/secret-card.js +@@ -7,6 +7,7 @@ import { CheckIcon, CloseIcon, TrashIcon } from './icons.js' + import { BotAvatar, BotMark } from './mark.js' + import { contentText, currentPart, historyLoading } from './text.js' + import { dateLocale, t } from './i18n.js' ++import { lockedWindow } from './schedules.js' + import { SPRING_POP, SPRING_SHAPE, spring } from './motion.js' + + const MIN_VALUE = 4 @@ -27,7 +27,7 @@ const insecure = () => typeof location !== 'undefined' && location.protocol === && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname) @@ -246,6 +254,29 @@ diff --git a/src/client/secret-card.js b/src/client/secret-card.js h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onDone }, t('Cancel')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: !ready, onClick: save }, t('Save key')))) } +@@ -256,6 +256,22 @@ export function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = useState(null) + const [adding, setAdding] = useState(false) + const secrets = roster.secrets ?? [] ++ // The host refuses saving, replacing and deleting a key in a read-only window, so the ++ // page lists the stored names and who may use them as text (#2474). ++ if (lockedWindow(roster)) { ++ return [ ++ h('p', { key: 'about', className: 'bt-secret-about' }, ++ t('Keys your Bots asked for. Bots never see the values: they use them as '), h('code', null, '$DSH_SECRET_NAME'), ++ t(' in their shell, and every value is replaced by '), h('code', null, '[secret:NAME]'), t(' before anything goes to a model.')), ++ h(Section, { key: 'list', title: t('Secrets · {count}', { count: secrets.length }) }, ++ secrets.length === 0 ++ ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('No keys yet. When a Bot needs one, it shows a card in its chat.'))) ++ : secrets.map(secret => h('div', { key: secret.name, className: 'bt-set-row' }, ++ h('span', { className: 'bt-set-copy' }, ++ h('span', { className: 'bt-secret-title' }, h(KeyIcon), h('code', { className: 'bt-secret-name' }, secret.name), secret.purpose ? h('span', { className: 'bt-secret-purpose' }, secret.purpose) : null), ++ h('span', { className: 'bt-set-hint' }, scopeLabel(secret.scope, roster)))))), ++ ] ++ } + return [ + h('p', { key: 'about', className: 'bt-secret-about' }, + t('Keys your Bots asked for. Bots never see the values: they use them as '), h('code', null, '$DSH_SECRET_NAME'), diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js @@ -305,6 +336,26 @@ diff --git a/src/client/settings.js b/src/client/settings.js function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() +@@ -47,6 +47,19 @@ function GeneralPage({ roster, actions, run }) { + clearTimeout(timer.current) + timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350) + } ++ // The host refuses `set-prefs` in a read-only window, so the page says what is chosen ++ // instead of drawing the controls that would ask for it (#2474). ++ if (lockedWindow(roster)) { ++ const accent = ACCENT_INKS.find(ink => INKS[ink][0] === prefs.accent) ++ return [ ++ h(SetSection, { key: 'look', title: t('Appearance') }, ++ h(SetRow, { label: t('Theme'), hint: t(themes[current].label) }), ++ h(SetRow, { label: t('Accent'), hint: accent ? t(capitalize(accent)) : prefs.accent ?? t('Default') })), ++ h(SetSection, { key: 'notify', title: t('Notifications') }, ++ h(SetRow, { label: t('In other chats'), hint: prefs.notifications !== false ? t('On') : t('Off') }), ++ NOTIFY_KINDS.map(([kind, label]) => h(SetRow, { key: kind, label: t(label), hint: prefs.notifications !== false && prefs.notify?.[kind] !== false ? t('On') : t('Off') }))), ++ ] ++ } + return [ + h(SetSection, { key: 'look', title: t('Appearance') }, + h(SetRow, { label: t('Theme'), hint: t('Colors for the whole team, on every device.') }, @@ -61,9 +61,17 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, @@ -326,7 +377,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js ] } -@@ -72,7 +72,9 @@ function GeneralPage({ roster, actions, run }) { +@@ -72,7 +72,16 @@ function GeneralPage({ roster, actions, run }) { function MainBotsSection({ roster, actions, run }) { const mains = (roster.mainBotIds ?? []).map(id => roster.byId[id]).filter(Boolean) const [first, ...others] = mains @@ -334,6 +385,13 @@ diff --git a/src/client/settings.js b/src/client/settings.js + // Only the user's own Bots can hold the role; a friend's Bot is never offered (#276). + const own = roster.bots.filter(bot => !bot.remote) + const candidates = own.filter(bot => !isMainOf(roster, bot.id)) ++ // Every control here writes `set-main`, which the host refuses in a read-only window; the ++ // section then names the Bots that hold the role (#2474). ++ if (lockedWindow(roster)) { ++ return h(SetSection, { title: t('Main Bot') }, ++ h(SetRow, { label: t('Main Bot'), hint: first ? first.name : undefined }), ++ others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }))) ++ } return h(SetSection, { title: t('Main Bot') }, h(SetRow, { label: t('Main Bot'), @@ -346,6 +404,18 @@ diff --git a/src/client/settings.js b/src/client/settings.js others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t('Move to first')), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, false)) }, t('Remove Main Bot role')))), +@@ -95,6 +95,11 @@ function MainBotsSection({ roster, actions, run }) { + const MOTIONS = [['quiet', 'Quiet'], ['normal', 'Normal'], ['lively', 'Lively']] + function MotionSection({ roster, actions, run }) { + const motion = roster.prefs?.motion === 'quiet' || roster.prefs?.motion === 'lively' ? roster.prefs.motion : 'normal' ++ // The three steps write `set-prefs`; a read-only window names the one in use (#2474). ++ if (lockedWindow(roster)) { ++ return h(SetSection, { title: t('Animation') }, ++ h(SetRow, { label: t('Bot animation'), hint: t(MOTIONS.find(([id]) => id === motion)?.[1] ?? 'Normal') })) ++ } + return h(SetSection, { title: t('Animation') }, + h(SetRow, { label: t('Bot animation') }, + h(Segmented, { label: t('Bot animation'), value: motion, className: 'bt-motion-seg' }, MOTIONS.map(([id, label]) => h('button', { @@ -112,8 +112,8 @@ function BotsPage({ roster, actions, run }) { ? h(CreateBot, { key: 'create', roster, actions, inline: true, onBack: () => setCreating(false), onCreated: bot => actions.openSettings('bots', bot.id) }) : h('div', { key: 'new', className: 'bt-actions' }, diff --git a/patches/ds-bot/files/test/settings-readonly-pages.test.js b/patches/ds-bot/files/test/settings-readonly-pages.test.js new file mode 100644 index 000000000..efa6b0408 --- /dev/null +++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js @@ -0,0 +1,233 @@ +// #2474: in a window that holds the team read-only the host refuses every write with +// `bot/read-only`, but seven Settings pages still drew their controls: General's theme +// select and switches, the Bots page's Main Bot and animation controls, and the live forms +// of a Bot, a group chat, Connectors, Capabilities and Secrets. Each page now reads the +// roster's own read-only sentence through `lockedWindow` and draws a plain-text view +// instead: no control anywhere, and the facts of the page kept as text. A writable window +// is untouched, and the standing sentence stays in the dialog header — once per page. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { SettingsDialog } from './src/client/settings.js'", + "export { InlineText, SwitchRow } from './src/client/bot-settings.js'", + "export { Segmented } from './src/client/motion.js'", + "export { ModelMenu } from './src/client/model-menu.js'", + "export { FolderMenu } from './src/client/folder-menu.js'", + ].join('\n'), + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useId: () => 'id', + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {}, createElement: () => ({ getContext: () => ({}) }) }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { SettingsDialog, InlineText, SwitchRow, Segmented, ModelMenu, FolderMenu } = module.exports + +const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' + +const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } +const writer = { id: 'writer', name: 'Writer', role: 'Editor', model: 'mock/m1', workDir: null, instructions: 'Write the copy.' } +const editor = { id: 'editor', name: 'Editor', role: '', model: 'mock/m1', workDir: null, instructions: '' } +const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [] } +const github = { id: 'github', name: 'GitHub', kind: 'tool', state: 'ready', account: 'xuke' } +const browser = { name: 'browser', kind: 'mcp', transport: 'stdio', description: 'Open pages' } +const token = { name: 'GITHUB_TOKEN', purpose: 'Push branches', scope: 'all', createdAt: 0 } +const baseRoster = { + bots: [chief, writer, editor], + byId: { chief, writer, editor }, + rooms: [room], + roomsById: { r1: room }, + mainBotIds: ['chief', 'writer'], + mainBotId: 'chief', + prefs: { theme: 'deepseek', motion: 'quiet', notifications: true }, + models: [{ id: 'm1', name: 'Mock One', served: true }], + connectors: [github], + capabilities: [browser], + secrets: [token], + humans: [], + remoteBots: [], + memory: true, +} +const lockedRoster = { ...baseRoster, readOnly: SENTENCE } + +// Every action that asks the host to change something. A locked page may draw an entry that +// navigates (New Bot, a Bot's row, Open board); it may not draw one that lands here. +const MUTATING = ['updateBot', 'deleteBot', 'createBot', 'duplicateBot', 'setMain', 'setPrefs', 'setFlags', 'openMemory', 'refreshModels', + 'updateRoom', 'deleteRoom', 'createRoom', 'saveCapability', 'deleteCapability', 'approveCapability', 'setBotCapability', + 'connectorConnect', 'connectorDisconnect', 'connectorRetry', 'connectorSend', 'connectorAllow', 'connectorBlock', 'connectorApps', + 'secretSet', 'secretUpdate', 'secretDelete', 'secretCancel', 'secretAllow', 'scheduleCreate', 'scheduleUpdate', 'scheduleDelete'] +const makeActions = (calls) => new Proxy({ openSettings() {}, closeSettings() {}, openSession() {}, notify() {}, showDetails() {} }, { + get: (target, name) => (MUTATING.includes(name) + ? () => { calls.push(String(name)) } + : target[name]), +}) + +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) } + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string') return node + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} + +// The widgets that render a control inside themselves: drawn is enough, they never run here. +const WIDGETS = new Set([InlineText, SwitchRow, Segmented, ModelMenu, FolderMenu]) +const nameOf = (node) => (typeof node.type === 'string' ? node.type : node.type?.name) +const WRITE_WIDGET_NAMES = new Set(['ConnectorRow', 'SecretRow', 'CapabilityRow', 'CapabilityForm', 'ScopeEditor', 'ValueEditor', + 'AddSecret', 'StandingApprovalsCard', 'BotCapabilitiesCard', 'BotConnectorsCard', 'BotSchedules', 'FlyBotDetails', + 'FlyBotRoomMembers', 'NewGroupForm', 'AllowedSenders']) + +const isWidget = (node) => WIDGETS.has(node.type) || WRITE_WIDGET_NAMES.has(nameOf(node)) + +// A page is drawn by the dialog one level down, so mounting it means calling it with the +// props the dialog handed it; child components are called too, one at a time, because the +// controls a locked window must not draw sit inside them. +const expand = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) expand(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 8) { + if (isWidget(node)) { out.push(node); expand(node.props?.children, out, depth + 1); return out } + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } + return expand(rendered, out, depth + 1) + } + out.push(node) + expand(node.props?.children, out, depth) + return out +} + +const mount = (roster, key, id, name, calls = []) => { + const actions = makeActions(calls) + slots = [] + effects = [] + index = 0 + const tree = SettingsDialog({ page: key, id, roster, actions, useUpdate: () => ({}) }) + const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === name) + assert.ok(node, `the dialog draws ${name}`) + slots = [] + effects = [] + index = 0 + const nodes = expand(node.type(node.props)) + return { tree, nodes, actions, calls } +} + +// A control is a field, a switch, or a widget that draws one. +const isControl = (node) => ['select', 'input', 'textarea'].includes(nameOf(node)) + || node.props?.role === 'switch' + || isWidget(node) + +const PAGES = [ + { key: 'general', id: null, name: 'GeneralPage', facts: ['DeepSeek', 'On'] }, + { key: 'bots', id: null, name: 'BotsPage', facts: ['Main Bot', 'Chief', 'Writer', 'Bot animation', 'Quiet'] }, + { key: 'bots', id: 'chief', name: 'BotSettingsForm', facts: ['Chief', 'Researcher', 'Run the team.'] }, + { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Read this first.'] }, + { key: 'connectors', id: null, name: 'ConnectorsPage', facts: ['GitHub', 'xuke'] }, + { key: 'capabilities', id: null, name: 'CapabilitiesPage', facts: ['browser', 'Open pages'] }, + { key: 'secrets', id: null, name: 'SecretsPage', facts: ['GITHUB_TOKEN', 'Push branches'] }, +] + +// Pressing what a locked page draws may navigate; it may not write. +const pressure = (nodes) => nodes.filter(node => nameOf(node) === 'button') + +test('a read-only window draws no write control on any of the seven Settings pages (#2474)', () => { + const controls = [] + const writes = [] + const missing = [] + for (const page of PAGES) { + const { nodes, calls } = mount(lockedRoster, page.key, page.id, page.name) + for (const control of nodes.filter(isControl)) controls.push(`${page.name}: ${nameOf(control)}`) + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point, not its answer */ } } + for (const call of calls) writes.push(`${page.name}: ${call}`) + const facts = text(nodes) + for (const fact of page.facts) if (!facts.includes(fact)) missing.push(`${page.name}: "${fact}"`) + } + assert.deepEqual(controls, [], 'a read-only window draws no control') + assert.deepEqual(writes, [], 'no control a read-only window draws reaches a write') + assert.deepEqual(missing, [], 'each page keeps its facts as text') +}) + +test('a read-only Bots page draws no Main Bot or animation control, and sends neither write (#2474)', () => { + // Product's probe on P-571 saw both defects from outside its own copy: both windows drew + // the same controls, and a press in the locked one still sent set-main and set-prefs. + const { nodes, calls } = mount(lockedRoster, 'bots', null, 'BotsPage') + assert.equal(nodes.filter(node => node.type === 'select').length, 0, 'no Main Bot picker') + assert.equal(nodes.filter(node => node.type === Segmented).length, 0, 'no animation picker') + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point */ } } + assert.deepEqual(calls, [], 'the page sends no set-main and no set-prefs') + assert.ok(nodes.some(node => nameOf(node) === 'button' && /New Bot/.test(text(node))), 'the New Bot entry stays; its form is locked since #2465') + assert.ok(text(nodes).includes('Chief'), 'the Main Bot in use is still named') +}) + +test('the standing sentence stays in the dialog header, once per page (#2474)', () => { + for (const page of PAGES) { + const tree = SettingsDialog({ page: page.key, id: page.id, roster: lockedRoster, actions: makeActions([]), useUpdate: () => ({}) }) + const hints = all(tree).filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes('bt-read-only')) + assert.equal(hints.length, 1, `one standing sentence on ${page.name}`) + assert.ok(text(hints[0]).includes(SENTENCE), 'the header keeps the host\'s own sentence') + } +}) + +test('a writable window keeps every page as it is today (#2474)', () => { + const counts = (nodes) => nodes.filter(isControl).length + const general = mount(baseRoster, 'general', null, 'GeneralPage').nodes + assert.equal(general.filter(node => node.type === 'select').length, 1, 'General keeps its theme select') + assert.equal(general.filter(node => node.type === 'input').length, 1, 'General keeps its colour field') + assert.equal(counts(general), 6, 'General keeps its four switches beside them') + + const bots = mount(baseRoster, 'bots', null, 'BotsPage').nodes + assert.equal(bots.filter(node => node.type === 'select').length, 2, 'Bots keeps both of its Main Bot selects') + assert.equal(bots.filter(node => node.type === Segmented).length, 1, 'Bots keeps its animation picker') + + assert.ok(counts(mount(baseRoster, 'bots', 'chief', 'BotSettingsForm').nodes) >= 3, 'a Bot keeps its live form') + assert.ok(counts(mount(baseRoster, 'groups', 'r1', 'GroupSettingsForm').nodes) >= 3, 'a group keeps its live form') + assert.ok(mount(baseRoster, 'connectors', null, 'ConnectorsPage').nodes.some(node => nameOf(node) === 'ConnectorRow'), 'Connectors keeps its live rows') + assert.ok(mount(baseRoster, 'capabilities', null, 'CapabilitiesPage').nodes.some(node => nameOf(node) === 'CapabilityRow'), 'Capabilities keeps its rows') + assert.ok(mount(baseRoster, 'secrets', null, 'SecretsPage').nodes.some(node => nameOf(node) === 'SecretRow'), 'Secrets keeps its rows') +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index ddaa271ba..4dc68db8c 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -80,6 +80,7 @@ "src/client/context-menu.js", "src/client/at-menu.js", "src/client/bot-settings.js", + "src/client/capability-settings.js", "src/client/user-message.js", "src/client/styles.js", "src/client/icons.js", From dd66b71ebff8d23f64233a683de3a0969414c983 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:59:38 +0800 Subject: [PATCH 2/3] client: read a group's human member name through the page's one reader The read-only group page listed a person straight from the room record while every other name on the page goes through `remoteBotName`, the shared reader for a stored name. One reader for one name keeps the page consistent and gives the cleaner the same say over a person's name that it already has over a friend's Bot. Fixes #2474 --- core/client.js | 3 ++- core/src/client/group-settings.js | 5 +++-- core/test/settings-readonly-pages.test.js | 4 ++-- patches/ds-bot/0008-client-bundle.patch | 5 +++-- patches/ds-bot/0012-room-membership.patch | 7 ++++--- patches/ds-bot/files/test/settings-readonly-pages.test.js | 4 ++-- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/core/client.js b/core/client.js index 5d01aeaeb..e86e2e92d 100644 --- a/core/client.js +++ b/core/client.js @@ -15296,7 +15296,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { Card, { title: t("Members · {count}", { count: face ? people2.length : members.length + humans.length + remotes.length }) }, face ? people2.map((person) => (0, import_react31.createElement)(Row, { key: person.key, label: facePersonLabel(person), hint: t("Human") })) : [ - humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: person.displayName || person.id, hint: t("Human") })), + // The stored name, cleaned by the same reader the friend's-Bot rows use (#2474). + humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t("Human") })), members.map((member) => (0, import_react31.createElement)(Row, { key: member.id, label: member.name, diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index d11ba52db..9e78a5aac 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -10,7 +10,7 @@ import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' import { StandingApprovalsCard } from './standing-approvals.js' import { FlyBotRoomMembers } from './flybot-slots.js' -import { remoteBotTitle } from '../shared/remote-label.js' +import { remoteBotName, remoteBotTitle } from '../shared/remote-label.js' export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], @@ -195,7 +195,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { face ? people.map(person => h(Row, { key: person.key, label: facePersonLabel(person), hint: t('Human') })) : [ - humans.map(person => h(Row, { key: person.id, label: person.displayName || person.id, hint: t('Human') })), + // The stored name, cleaned by the same reader the friend's-Bot rows use (#2474). + humans.map(person => h(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t('Human') })), members.map(member => h(Row, { key: member.id, label: member.name, diff --git a/core/test/settings-readonly-pages.test.js b/core/test/settings-readonly-pages.test.js index efa6b0408..79e14e345 100644 --- a/core/test/settings-readonly-pages.test.js +++ b/core/test/settings-readonly-pages.test.js @@ -67,7 +67,7 @@ const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } const writer = { id: 'writer', name: 'Writer', role: 'Editor', model: 'mock/m1', workDir: null, instructions: 'Write the copy.' } const editor = { id: 'editor', name: 'Editor', role: '', model: 'mock/m1', workDir: null, instructions: '' } -const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [] } +const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [{ kind: 'human', id: 'owner-1', displayName: 'Alice', role: 'admin' }] } const github = { id: 'github', name: 'GitHub', kind: 'tool', state: 'ready', account: 'xuke' } const browser = { name: 'browser', kind: 'mcp', transport: 'stdio', description: 'Open pages' } const token = { name: 'GITHUB_TOKEN', purpose: 'Push branches', scope: 'all', createdAt: 0 } @@ -167,7 +167,7 @@ const PAGES = [ { key: 'general', id: null, name: 'GeneralPage', facts: ['DeepSeek', 'On'] }, { key: 'bots', id: null, name: 'BotsPage', facts: ['Main Bot', 'Chief', 'Writer', 'Bot animation', 'Quiet'] }, { key: 'bots', id: 'chief', name: 'BotSettingsForm', facts: ['Chief', 'Researcher', 'Run the team.'] }, - { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Read this first.'] }, + { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Alice', 'Read this first.'] }, { key: 'connectors', id: null, name: 'ConnectorsPage', facts: ['GitHub', 'xuke'] }, { key: 'capabilities', id: null, name: 'CapabilitiesPage', facts: ['browser', 'Open pages'] }, { key: 'secrets', id: null, name: 'SecretsPage', facts: ['GITHUB_TOKEN', 'Push branches'] }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 167cbbc72..5f8b88ea5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -17026,7 +17026,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,1401 +6759,1902 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,1401 +6759,1903 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -19442,7 +19442,8 @@ diff --git a/client.js b/client.js + Card, + { title: t("Members · {count}", { count: face ? people2.length : members.length + humans.length + remotes.length }) }, + face ? people2.map((person) => (0, import_react31.createElement)(Row, { key: person.key, label: facePersonLabel(person), hint: t("Human") })) : [ -+ humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: person.displayName || person.id, hint: t("Human") })), ++ // The stored name, cleaned by the same reader the friend's-Bot rows use (#2474). ++ humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t("Human") })), + members.map((member) => (0, import_react31.createElement)(Row, { + key: member.id, + label: member.name, diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 40de66b51..82efc1da3 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -504,7 +504,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js import { t } from './i18n.js' +import { StandingApprovalsCard } from './standing-approvals.js' +import { FlyBotRoomMembers } from './flybot-slots.js' -+import { remoteBotTitle } from '../shared/remote-label.js' ++import { remoteBotName, remoteBotTitle } from '../shared/remote-label.js' export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], @@ -646,7 +646,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js } // A group chat's settings as grouped cards: its name and notice, who answers, its -@@ -31,11 +31,48 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,11 +31,49 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -683,7 +683,8 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + face + ? people.map(person => h(Row, { key: person.key, label: facePersonLabel(person), hint: t('Human') })) + : [ -+ humans.map(person => h(Row, { key: person.id, label: person.displayName || person.id, hint: t('Human') })), ++ // The stored name, cleaned by the same reader the friend's-Bot rows use (#2474). ++ humans.map(person => h(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t('Human') })), + members.map(member => h(Row, { + key: member.id, + label: member.name, diff --git a/patches/ds-bot/files/test/settings-readonly-pages.test.js b/patches/ds-bot/files/test/settings-readonly-pages.test.js index efa6b0408..79e14e345 100644 --- a/patches/ds-bot/files/test/settings-readonly-pages.test.js +++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js @@ -67,7 +67,7 @@ const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web const chief = { id: 'chief', name: 'Chief', role: 'Researcher', model: 'mock/m1', workDir: '/work/chief', instructions: 'Run the team.', pinned: true } const writer = { id: 'writer', name: 'Writer', role: 'Editor', model: 'mock/m1', workDir: null, instructions: 'Write the copy.' } const editor = { id: 'editor', name: 'Editor', role: '', model: 'mock/m1', workDir: null, instructions: '' } -const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [] } +const room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief', 'writer'], mode: 'admin', notice: 'Read this first.', named: true, workDir: '/work/ops', participants: [{ kind: 'human', id: 'owner-1', displayName: 'Alice', role: 'admin' }] } const github = { id: 'github', name: 'GitHub', kind: 'tool', state: 'ready', account: 'xuke' } const browser = { name: 'browser', kind: 'mcp', transport: 'stdio', description: 'Open pages' } const token = { name: 'GITHUB_TOKEN', purpose: 'Push branches', scope: 'all', createdAt: 0 } @@ -167,7 +167,7 @@ const PAGES = [ { key: 'general', id: null, name: 'GeneralPage', facts: ['DeepSeek', 'On'] }, { key: 'bots', id: null, name: 'BotsPage', facts: ['Main Bot', 'Chief', 'Writer', 'Bot animation', 'Quiet'] }, { key: 'bots', id: 'chief', name: 'BotSettingsForm', facts: ['Chief', 'Researcher', 'Run the team.'] }, - { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Read this first.'] }, + { key: 'groups', id: 'r1', name: 'GroupSettingsForm', facts: ['Ops', 'Chief', 'Writer', 'Alice', 'Read this first.'] }, { key: 'connectors', id: null, name: 'ConnectorsPage', facts: ['GitHub', 'xuke'] }, { key: 'capabilities', id: null, name: 'CapabilitiesPage', facts: ['browser', 'Open pages'] }, { key: 'secrets', id: null, name: 'SecretsPage', facts: ['GITHUB_TOKEN', 'Push branches'] }, From 01fdf867365ea12c84452d042f2462454ab2c171 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:18:23 +0800 Subject: [PATCH 3/3] client: keep a Bot's settings hooks constant when the lock lifts (#2474) The locked view of BotSettingsForm returned before the refreshModels effect, so a locked render ran four hooks and a writable one five. When the other window closed, the host's read-only sentence went away under a page that was already mounted and React unmounted the whole Settings dialog with "Rendered more hooks than during the previous render.". Move the effect above the read-only branch so both views run the same hooks, and cover the lock-to-writable flip on all seven Settings pages in the read-only test. --- core/client.js | 6 +- core/src/client/bot-settings.js | 6 +- core/test/settings-readonly-pages.test.js | 68 +++++++++++++++---- patches/ds-bot/0003-flybot-api-client.patch | 11 ++- patches/ds-bot/0008-client-bundle.patch | 6 +- .../test/settings-readonly-pages.test.js | 68 +++++++++++++++---- 6 files changed, 128 insertions(+), 37 deletions(-) diff --git a/core/client.js b/core/client.js index e86e2e92d..5edb23447 100644 --- a/core/client.js +++ b/core/client.js @@ -14912,6 +14912,9 @@ function BotSettingsForm({ bot, roster, actions, run }) { return () => clearTimeout(timer); }, [confirming]); if (bot.remote) return null; + (0, import_react30.useEffect)(() => { + void actions.refreshModels?.(); + }, [bot.id]); const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; @@ -14958,9 +14961,6 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; - (0, import_react30.useEffect)(() => { - void actions.refreshModels?.(); - }, [bot.id]); return (0, import_react30.createElement)( "div", { className: "bt-bs" }, diff --git a/core/src/client/bot-settings.js b/core/src/client/bot-settings.js index ceb38159c..ebdfd01d1 100644 --- a/core/src/client/bot-settings.js +++ b/core/src/client/bot-settings.js @@ -205,6 +205,10 @@ export function BotSettingsForm({ bot, roster, actions, run }) { return () => clearTimeout(timer) }, [confirming]) if (bot.remote) return null + // DSH settings can change the model list at any time; the hint should match it. The hook sits + // above the read-only branch because the lock can lift under a page that is already mounted: + // both views must run the same hooks in the same order, or React unmounts the whole dialog (#2474). + useEffect(() => { void actions.refreshModels?.() }, [bot.id]) const isMainBot = isMainOf(roster, bot.id) const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 const isTile = roster.mainBotId === bot.id @@ -229,8 +233,6 @@ export function BotSettingsForm({ bot, roster, actions, run }) { const save = patch => run(actions.updateBot(bot.id, patch)) const duplicate = () => run(actions.duplicateBot(bot.id).then(copy => actions.openSession(copy.id))) const remove = () => { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true) } - // DSH settings can change the model list at any time; the hint should match it. - useEffect(() => { void actions.refreshModels?.() }, [bot.id]) return h('div', { className: 'bt-bs' }, h('div', { className: 'bt-bs-hero' }, h('div', { className: 'bt-avatar-anchor' }, diff --git a/core/test/settings-readonly-pages.test.js b/core/test/settings-readonly-pages.test.js index 79e14e345..9dc274f26 100644 --- a/core/test/settings-readonly-pages.test.js +++ b/core/test/settings-readonly-pages.test.js @@ -30,20 +30,30 @@ const h = (type, props, ...children) => ({ type, props: { ...props, children: ch let slots = [] let index = 0 let effects = [] +// React counts a component's hooks on every render and refuses a count that changed under the +// same instance: one more hook throws during that render, one fewer after it. Keep both rules +// and React's own messages, so a roster that flips under a mounted page fails here the way +// React 18.3.1 fails it (#2474). +let previousHookCount = null +const hook = () => { + const at = index++ + if (previousHookCount !== null && at >= previousHookCount) throw new Error('Rendered more hooks than during the previous render.') + return at +} const react = { createElement: h, Component: class {}, Fragment: Symbol('F'), - useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useMemo: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] }, useState: (initial) => { - const at = index++ + const at = hook() if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] }, - useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, - useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, - useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, - useLayoutEffect() {}, - useId: () => 'id', - useSyncExternalStore: (_subscribe, get) => get(), + useCallback: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = hook(); if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = hook(); if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect: () => { hook() }, + useId: () => { hook(); return 'id' }, + useSyncExternalStore: (_subscribe, get) => { hook(); return get() }, } const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } const module = { exports: {} } @@ -125,21 +135,23 @@ const isWidget = (node) => WIDGETS.has(node.type) || WRITE_WIDGET_NAMES.has(name // A page is drawn by the dialog one level down, so mounting it means calling it with the // props the dialog handed it; child components are called too, one at a time, because the // controls a locked window must not draw sit inside them. -const expand = (node, out = [], depth = 0) => { - if (Array.isArray(node)) { for (const item of node) expand(item, out, depth); return out } +const expand = (node, out = [], depth = 0, deep = false) => { + if (Array.isArray(node)) { for (const item of node) expand(item, out, depth, deep); return out } if (!node || typeof node !== 'object') return out if (typeof node.type === 'function' && depth < 8) { - if (isWidget(node)) { out.push(node); expand(node.props?.children, out, depth + 1); return out } + if (isWidget(node) && !deep) { out.push(node); expand(node.props?.children, out, depth + 1, deep); return out } const saved = slots const savedIndex = index + const savedCount = previousHookCount slots = [] index = 0 + previousHookCount = null let rendered - try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } - return expand(rendered, out, depth + 1) + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex; previousHookCount = savedCount } + return expand(rendered, out, depth + 1, deep) } out.push(node) - expand(node.props?.children, out, depth) + expand(node.props?.children, out, depth, deep) return out } @@ -148,12 +160,14 @@ const mount = (roster, key, id, name, calls = []) => { slots = [] effects = [] index = 0 + previousHookCount = null const tree = SettingsDialog({ page: key, id, roster, actions, useUpdate: () => ({}) }) const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === name) assert.ok(node, `the dialog draws ${name}`) slots = [] effects = [] index = 0 + previousHookCount = null const nodes = expand(node.type(node.props)) return { tree, nodes, actions, calls } } @@ -231,3 +245,29 @@ test('a writable window keeps every page as it is today (#2474)', () => { assert.ok(mount(baseRoster, 'capabilities', null, 'CapabilitiesPage').nodes.some(node => nameOf(node) === 'CapabilityRow'), 'Capabilities keeps its rows') assert.ok(mount(baseRoster, 'secrets', null, 'SecretsPage').nodes.some(node => nameOf(node) === 'SecretRow'), 'Secrets keeps its rows') }) + +// The window can be unlocked under a page that is already on screen: the other window holding +// the team closes, the host's read-only sentence goes away, and settings.js keeps the same +// dialog instance (its key is the page and the id, not the lock). A page whose locked view +// returns before one of its hooks then changes its hook count, and React unmounts the whole +// Settings surface with "Rendered more hooks than during the previous render." (#2474). +test('every Settings page re-renders when the window unlocks under it (#2474)', () => { + for (const page of PAGES) { + previousHookCount = null + const tree = SettingsDialog({ page: page.key, id: page.id, roster: lockedRoster, actions: makeActions([]), useUpdate: () => ({}) }) + const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === page.name) + assert.ok(node, `the dialog draws ${page.name}`) + slots = [] + effects = [] + index = 0 + node.type(node.props) // the locked render, already on screen + previousHookCount = index + index = 0 + const rendered = node.type({ ...node.props, roster: baseRoster }) // the same instance, writable now + if (index < previousHookCount) throw new Error('Rendered fewer hooks than expected. This may be caused by an accidental early return statement.') + const nodes = expand(rendered) + const html = text(expand(rendered, [], 0, true)) + assert.ok(html.length > 0, `${page.name} still draws its page after the flip`) + assert.ok(nodes.filter(isControl).length > 0, `${page.name} still draws its live controls after the flip`) + } +}) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 2fba57cc2..47ac43dbc 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -407,11 +407,15 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js return h(Card, { title: t('Memory') }, h('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) }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, label)), -@@ -199,9 +199,28 @@ export function BotSettingsForm({ bot, roster, actions, run }) { +@@ -199,14 +199,35 @@ export function BotSettingsForm({ bot, roster, actions, run }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) + if (bot.remote) return null ++ // DSH settings can change the model list at any time; the hint should match it. The hook sits ++ // above the read-only branch because the lock can lift under a page that is already mounted: ++ // both views must run the same hooks in the same order, or React unmounts the whole dialog (#2474). ++ useEffect(() => { void actions.refreshModels?.() }, [bot.id]) const isMainBot = isMainOf(roster, bot.id) const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1 const isTile = roster.mainBotId === bot.id @@ -436,6 +440,11 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js const save = patch => run(actions.updateBot(bot.id, patch)) const duplicate = () => run(actions.duplicateBot(bot.id).then(copy => actions.openSession(copy.id))) const remove = () => { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true) } +- // DSH settings can change the model list at any time; the hint should match it. +- useEffect(() => { void actions.refreshModels?.() }, [bot.id]) + return h('div', { className: 'bt-bs' }, + h('div', { className: 'bt-bs-hero' }, + h('div', { className: 'bt-avatar-anchor' }, @@ -222,8 +222,9 @@ export function BotSettingsForm({ bot, roster, actions, run }) { h(Row, { label: t('Label'), hint: t('Optional. Shown under the name.') }, h(InlineText, { label: t('Label'), value: bot.role, placeholder: t('Research, admin…'), maxLength: 24, onSave: role => save({ role }) }))), h(Card, { title: t('Model') }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 5f8b88ea5..052c69e46 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -18885,6 +18885,9 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [confirming]); + if (bot.remote) return null; ++ (0, import_react30.useEffect)(() => { ++ void actions.refreshModels?.(); ++ }, [bot.id]); + const isMainBot = isMainOf(roster, bot.id); + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; + const isTile = roster.mainBotId === bot.id; @@ -18936,9 +18939,6 @@ diff --git a/client.js b/client.js - const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; - const busy = answer?.pending === true; - return (0, import_react17.createElement)( -+ (0, import_react30.useEffect)(() => { -+ void actions.refreshModels?.(); -+ }, [bot.id]); + return (0, import_react30.createElement)( "div", - { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, diff --git a/patches/ds-bot/files/test/settings-readonly-pages.test.js b/patches/ds-bot/files/test/settings-readonly-pages.test.js index 79e14e345..9dc274f26 100644 --- a/patches/ds-bot/files/test/settings-readonly-pages.test.js +++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js @@ -30,20 +30,30 @@ const h = (type, props, ...children) => ({ type, props: { ...props, children: ch let slots = [] let index = 0 let effects = [] +// React counts a component's hooks on every render and refuses a count that changed under the +// same instance: one more hook throws during that render, one fewer after it. Keep both rules +// and React's own messages, so a roster that flips under a mounted page fails here the way +// React 18.3.1 fails it (#2474). +let previousHookCount = null +const hook = () => { + const at = index++ + if (previousHookCount !== null && at >= previousHookCount) throw new Error('Rendered more hooks than during the previous render.') + return at +} const react = { createElement: h, Component: class {}, Fragment: Symbol('F'), - useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useMemo: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] }, useState: (initial) => { - const at = index++ + const at = hook() if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] }, - useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, - useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, - useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, - useLayoutEffect() {}, - useId: () => 'id', - useSyncExternalStore: (_subscribe, get) => get(), + useCallback: (fn) => { const at = hook(); if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = hook(); if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = hook(); if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect: () => { hook() }, + useId: () => { hook(); return 'id' }, + useSyncExternalStore: (_subscribe, get) => { hook(); return get() }, } const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } const module = { exports: {} } @@ -125,21 +135,23 @@ const isWidget = (node) => WIDGETS.has(node.type) || WRITE_WIDGET_NAMES.has(name // A page is drawn by the dialog one level down, so mounting it means calling it with the // props the dialog handed it; child components are called too, one at a time, because the // controls a locked window must not draw sit inside them. -const expand = (node, out = [], depth = 0) => { - if (Array.isArray(node)) { for (const item of node) expand(item, out, depth); return out } +const expand = (node, out = [], depth = 0, deep = false) => { + if (Array.isArray(node)) { for (const item of node) expand(item, out, depth, deep); return out } if (!node || typeof node !== 'object') return out if (typeof node.type === 'function' && depth < 8) { - if (isWidget(node)) { out.push(node); expand(node.props?.children, out, depth + 1); return out } + if (isWidget(node) && !deep) { out.push(node); expand(node.props?.children, out, depth + 1, deep); return out } const saved = slots const savedIndex = index + const savedCount = previousHookCount slots = [] index = 0 + previousHookCount = null let rendered - try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex } - return expand(rendered, out, depth + 1) + try { rendered = node.type({ ...node.props }) } catch { out.push(node); return out } finally { slots = saved; index = savedIndex; previousHookCount = savedCount } + return expand(rendered, out, depth + 1, deep) } out.push(node) - expand(node.props?.children, out, depth) + expand(node.props?.children, out, depth, deep) return out } @@ -148,12 +160,14 @@ const mount = (roster, key, id, name, calls = []) => { slots = [] effects = [] index = 0 + previousHookCount = null const tree = SettingsDialog({ page: key, id, roster, actions, useUpdate: () => ({}) }) const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === name) assert.ok(node, `the dialog draws ${name}`) slots = [] effects = [] index = 0 + previousHookCount = null const nodes = expand(node.type(node.props)) return { tree, nodes, actions, calls } } @@ -231,3 +245,29 @@ test('a writable window keeps every page as it is today (#2474)', () => { assert.ok(mount(baseRoster, 'capabilities', null, 'CapabilitiesPage').nodes.some(node => nameOf(node) === 'CapabilityRow'), 'Capabilities keeps its rows') assert.ok(mount(baseRoster, 'secrets', null, 'SecretsPage').nodes.some(node => nameOf(node) === 'SecretRow'), 'Secrets keeps its rows') }) + +// The window can be unlocked under a page that is already on screen: the other window holding +// the team closes, the host's read-only sentence goes away, and settings.js keeps the same +// dialog instance (its key is the page and the id, not the lock). A page whose locked view +// returns before one of its hooks then changes its hook count, and React unmounts the whole +// Settings surface with "Rendered more hooks than during the previous render." (#2474). +test('every Settings page re-renders when the window unlocks under it (#2474)', () => { + for (const page of PAGES) { + previousHookCount = null + const tree = SettingsDialog({ page: page.key, id: page.id, roster: lockedRoster, actions: makeActions([]), useUpdate: () => ({}) }) + const node = all(tree).find(entry => typeof entry.type === 'function' && entry.type.name === page.name) + assert.ok(node, `the dialog draws ${page.name}`) + slots = [] + effects = [] + index = 0 + node.type(node.props) // the locked render, already on screen + previousHookCount = index + index = 0 + const rendered = node.type({ ...node.props, roster: baseRoster }) // the same instance, writable now + if (index < previousHookCount) throw new Error('Rendered fewer hooks than expected. This may be caused by an accidental early return statement.') + const nodes = expand(rendered) + const html = text(expand(rendered, [], 0, true)) + assert.ok(html.length > 0, `${page.name} still draws its page after the flip`) + assert.ok(nodes.filter(isControl).length > 0, `${page.name} still draws its live controls after the flip`) + } +})