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..5edb23447 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" },
@@ -14862,18 +14912,55 @@ 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;
+ 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 = () => {
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" },
@@ -15190,6 +15277,38 @@ 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") })) : [
+ // 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,
+ 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 +18862,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 +19266,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 +19347,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 +19394,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..ebdfd01d1 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'
@@ -205,14 +205,34 @@ 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
+ // 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) }
- // 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/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..9e78a5aac 100644
--- a/core/src/client/group-settings.js
+++ b/core/src/client/group-settings.js
@@ -5,11 +5,12 @@ 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'
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.'],
@@ -181,6 +182,30 @@ 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') }))
+ : [
+ // 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,
+ 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..9dc274f26
--- /dev/null
+++ b/core/test/settings-readonly-pages.test.js
@@ -0,0 +1,273 @@
+// #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 = []
+// 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 = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] },
+ useState: (initial) => {
+ 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 = 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: {} }
+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: [{ 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 }
+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, 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) && !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; previousHookCount = savedCount }
+ return expand(rendered, out, depth + 1, deep)
+ }
+ out.push(node)
+ expand(node.props?.children, out, depth, deep)
+ return out
+}
+
+const mount = (roster, key, id, name, calls = []) => {
+ const actions = makeActions(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 }
+}
+
+// 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', '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'] },
+]
+
+// 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')
+})
+
+// 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/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..47ac43dbc 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,14 +407,44 @@ 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,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
++ // 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) }
+- // 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') },
@@ -487,6 +518,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..052c69e46 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}}
-+`;
-+
+@@ -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 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
++ 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,1903 @@ 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 !== "");
-+ 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)(
++ 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";
++ 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);
@@ -18499,9 +18885,49 @@ 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;
++ 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 = () => {
@@ -18513,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 },
@@ -19000,6 +19423,38 @@ 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") })) : [
++ // 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,
++ 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 +21068,7 @@ diff --git a/client.js b/client.js
)
);
}
-
+-
-// src/client/group-settings.js
-var import_react23 = require("react");
-var ROOM_MODES = [
@@ -20624,6 +21079,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 +21121,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 +21140,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 +21205,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 +21286,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 +21329,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 +21401,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 +23016,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 +23213,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 +23646,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 +23692,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 +23741,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 +23790,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 +23847,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 +24299,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 +24447,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 +24718,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..82efc1da3 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,11 +499,12 @@ 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'
+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.'],
@@ -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,49 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
const timer = setTimeout(() => setConfirming(false), 4000)
return () => clearTimeout(timer)
}, [confirming])
@@ -669,6 +670,33 @@ 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') }))
++ : [
++ // 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,
++ 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..9dc274f26
--- /dev/null
+++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js
@@ -0,0 +1,273 @@
+// #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 = []
+// 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 = hook(); if (!(at in slots)) slots[at] = fn(); return slots[at] },
+ useState: (initial) => {
+ 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 = 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: {} }
+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: [{ 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 }
+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, 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) && !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; previousHookCount = savedCount }
+ return expand(rendered, out, depth + 1, deep)
+ }
+ out.push(node)
+ expand(node.props?.children, out, depth, deep)
+ return out
+}
+
+const mount = (roster, key, id, name, calls = []) => {
+ const actions = makeActions(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 }
+}
+
+// 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', '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'] },
+]
+
+// 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')
+})
+
+// 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/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",