From 0318eaac689ed16beb6dee23eec875b6c627e40e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:11:50 +0800 Subject: [PATCH] client: put the read-only Reload where the sentence is The host's sentence tells the reader to close the other window and reload, but only the sidebar and the refusal toast offered that entry: the Settings header, the drawer's three hint sites, the Inbox dialog and the Memory panel kept the sentence with nothing to answer it, and inside the Inbox modal the sidebar's own button cannot be reached at all. One shared helper (client/read-only.js, readOnlyLine) draws the host's own words in a role="status" line with one Reload outside it, running the same actions.reloadTeam read the sidebar runs and reporting a failure through reportError(error, 'reload'). The caller passes h rather than the module importing react, so the generated bundle does not renumber every module's react import: that alone turns this into a two-thousand-line client.js diff. Fixes #2502 --- changelog.d/2502-readonly-reload.added.md | 1 + core/client.js | 53 +++- core/src/client/details-drawer.js | 9 +- core/src/client/inbox.js | 3 +- core/src/client/memory-panel.js | 3 +- core/src/client/read-only.js | 29 +++ core/src/client/settings.js | 7 +- core/src/client/styles.js | 7 + .../details-bot-settings-readonly.test.js | 5 +- core/test/readonly-reload-lines.test.js | 246 ++++++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 5 +- patches/ds-bot/0003-flybot-api-client.patch | 11 +- patches/ds-bot/0008-client-bundle.patch | 186 +++++++------ patches/ds-bot/0012-room-membership.patch | 12 +- patches/ds-bot/0014-flybot-branding.patch | 12 +- patches/ds-bot/files/src/client/inbox.js | 3 +- patches/ds-bot/files/src/client/read-only.js | 29 +++ .../details-bot-settings-readonly.test.js | 5 +- .../files/test/readonly-reload-lines.test.js | 246 ++++++++++++++++++ 20 files changed, 750 insertions(+), 124 deletions(-) create mode 100644 changelog.d/2502-readonly-reload.added.md create mode 100644 core/src/client/read-only.js create mode 100644 core/test/readonly-reload-lines.test.js create mode 100644 patches/ds-bot/files/src/client/read-only.js create mode 100644 patches/ds-bot/files/test/readonly-reload-lines.test.js diff --git a/changelog.d/2502-readonly-reload.added.md b/changelog.d/2502-readonly-reload.added.md new file mode 100644 index 000000000..816404eb1 --- /dev/null +++ b/changelog.d/2502-readonly-reload.added.md @@ -0,0 +1 @@ +The Settings header, the details drawer, the Inbox and the Memory panel now draw a **Reload** beside the host's read-only sentence, so a window that was told to reload can do it where it stands instead of closing the panel and reaching for the sidebar ([#2502](https://github.com/ThinkFlowLab/FlyBot/issues/2502)). diff --git a/core/client.js b/core/client.js index 6030223e0..bc30c8c0b 100644 --- a/core/client.js +++ b/core/client.js @@ -5238,6 +5238,13 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} +/* #2502: the host sentence and its Reload entry share one row; the sidebar's own rule sizes + the button, this only places it beside the sentence instead of under it. */ +.bt-readonly-line{display:flex;align-items:center;gap:8px} +.bt-readonly-line>.bt-hint{flex:1 1 auto;min-width:0} +.bt-readonly-line>.bt-read-only-reload{flex:none;margin:0} +.bt-readonly-line-head{padding:0 20px 8px} +.bt-readonly-line-head>.bt-hint{padding:0;text-align:left} .bt-nc-spacer{flex:1} .bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} .bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} @@ -6885,7 +6892,7 @@ function useInlineEntry(policy) { } function HeldReplyActions({ held, actions, roster }) { const locked = lockedWindow(roster); - const readOnlyLine = () => (0, import_react10.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)); + const readOnlyLine2 = () => (0, import_react10.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)); const custom = useInlineEntry(held?.by); const [busy, setBusy] = (0, import_react10.useState)(false); const [error, setError] = (0, import_react10.useState)(""); @@ -6915,7 +6922,7 @@ function HeldReplyActions({ held, actions, roster }) { (0, import_react10.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a // different field elsewhere. A failure that can never succeed draws no button on any surface. - failed && canRetry(held) ? locked ? readOnlyLine() : (0, import_react10.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + failed && canRetry(held) ? locked ? readOnlyLine2() : (0, import_react10.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, error ? (0, import_react10.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ); } @@ -6928,7 +6935,7 @@ function HeldReplyActions({ held, actions, roster }) { (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), reason ? (0, import_react10.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, - locked ? readOnlyLine() : (0, import_react10.createElement)( + locked ? readOnlyLine2() : (0, import_react10.createElement)( "div", { className: "bt-flybot-approval-actions" }, (0, import_react10.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), @@ -8725,6 +8732,24 @@ function progressOf(task) { return Math.max(0, Math.min(100, Math.round(value))); } +// src/client/read-only.js +var readOnlySaid = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; +function readOnlyLine(h50, roster, actions, { key, className, style, reload = true } = {}) { + if (!readOnlySaid(roster)) return null; + return h50( + "div", + { key, className: ["bt-readonly-line", className].filter(Boolean).join(" "), style }, + h50("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)), + reload ? h50("button", { + type: "button", + className: "bt-read-only-reload", + onClick: () => { + void Promise.resolve(actions?.reloadTeam?.()).catch((error) => actions?.reportError?.(error, "reload")); + } + }, t("Reload")) : null + ); +} + // src/client/inbox.js var import_react15 = require("react"); var KEEP_MS = 7 * 864e5; @@ -9359,7 +9384,7 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) (0, import_react15.createElement)( "div", { className: "bt-mem-body bt-inbox-body" }, - ...locked ? [(0, import_react15.createElement)("p", { key: "readonly", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], + ...locked ? [readOnlyLine(import_react15.createElement, roster, actions, { key: "readonly" })] : [], (0, import_react15.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), GROUPS.map((group) => { const settled = group.id === "done" ? model.doneAll ?? groups.done : null; @@ -10719,7 +10744,7 @@ function TeamSidebar(props) { return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); }, [roster, updated]); const main = roster.byId[roster.mainBotId]; - const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; + const readOnlySaid2 = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; const readOnlyReload = (0, import_react19.createElement)("button", { type: "button", className: "bt-read-only-reload", @@ -10758,10 +10783,10 @@ function TeamSidebar(props) { (0, import_react19.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), (0, import_react19.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react19.createElement)(PlusIcon)) ), - wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, - wide && readOnlySaid ? readOnlyReload : null, - !wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, - !wide && readOnlySaid ? readOnlyReload : null, + wide && readOnlySaid2 ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + wide && readOnlySaid2 ? readOnlyReload : null, + !wide && readOnlySaid2 ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, + !wide && readOnlySaid2 ? readOnlyReload : null, (0, import_react19.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), (0, import_react19.createElement)(KanbanRow, { wide, useUi, actions }), (0, import_react19.createElement)(ProjectsNav, { wide }), @@ -14604,7 +14629,7 @@ function MemoryDialog({ bot, actions, roster, leaving = false }) { (0, import_react28.createElement)( "div", { className: "bt-mem-body" }, - locked ? (0, import_react28.createElement)("p", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + readOnlyLine(import_react28.createElement, roster, actions), error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove: locked ? null : remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) ), @@ -19566,7 +19591,7 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false // A window that holds the team read-only still opens every page here, so the header says // why nothing can be changed, in the host's own sentence and above the refusal below it: // the standing state first, the answer to one pressed control closest to that control. - typeof roster?.readOnly === "string" && roster.readOnly.trim() ? (0, import_react42.createElement)("div", { className: "bt-hint bt-read-only", role: "status", style: { padding: "0 20px 8px", textAlign: "left" } }, hostText(roster.readOnly)) : null, + readOnlyLine(import_react42.createElement, roster, actions, { key: "readonly", className: "bt-readonly-line-head" }), shown === "usage" ? null : errorLine, shown === "usage" ? null : (0, import_react42.createElement)( "div", @@ -20199,7 +20224,7 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti // P-544: the group overview's writes are gone in a locked window, so it says why once, above // the reads it still draws. The approval card used to carry its own copy (#2381); one page, // one sentence. - locked ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + readOnlyLine(import_react44.createElement, roster, actions, { key: "read-only" }), (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), invited && room2.sharedFrom && !face ? (0, import_react44.createElement)( "div", @@ -20340,7 +20365,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, // #2387: the lock is the window's, not the Bot's — a read-only window draws the host's sentence // in place of a group's form too, exactly as it already did for a Bot's. - lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) + lockedWindow(roster) ? readOnlyLine(import_react44.createElement, roster, actions) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } @@ -20434,7 +20459,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a // locked window refuses every one of them. Say so once at the top and draw the read half only, // which is the values themselves (P-547) — the name above is already text, and the settings // gear still opens the page that explains the window. - lockedWindow(roster) ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + lockedWindow(roster) ? readOnlyLine(import_react44.createElement, roster, actions, { key: "read-only" }) : null, lockedWindow(roster) ? (0, import_react44.createElement)(ReadOnlyOverview, { key: "read", bot, roster }) : null, lockedWindow(roster) ? null : (0, import_react44.createElement)( "div", diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index bb88487e5..2cfe1dd97 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -13,10 +13,11 @@ import { MemoryRow } from './memory-panel.js' import { RoomSchedules, ScheduleSummary, lockedWindow } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' -import { hostText, t } from './i18n.js' +import { t } from './i18n.js' import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' import { call } from './api.js' +import { readOnlyLine } from './read-only.js' const TAB_LABELS = { details: 'Details', browser: 'Browser' } @@ -221,7 +222,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) // P-544: the group overview's writes are gone in a locked window, so it says why once, above // the reads it still draws. The approval card used to carry its own copy (#2381); one page, // one sentence. - locked ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, + readOnlyLine(h, roster, actions, { key: 'read-only' }), h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), invited && room.sharedFrom && !face ? h('div', { key: 'from', className: 'bt-shared-from' }, @@ -330,7 +331,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro // #2387: the lock is the window's, not the Bot's — a read-only window draws the host's sentence // in place of a group's form too, exactly as it already did for a Bot's. lockedWindow(roster) - ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) + ? readOnlyLine(h, roster, actions) : bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ] } @@ -411,7 +412,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro // locked window refuses every one of them. Say so once at the top and draw the read half only, // which is the values themselves (P-547) — the name above is already text, and the settings // gear still opens the page that explains the window. - lockedWindow(roster) ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, + lockedWindow(roster) ? readOnlyLine(h, roster, actions, { key: 'read-only' }) : null, lockedWindow(roster) ? h(ReadOnlyOverview, { key: 'read', bot, roster }) : null, lockedWindow(roster) ? null : h('div', { key: 'model', className: 'bt-drawer-model' }, h('div', { className: 'bt-section-title' }, t('Model')), diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index 8a81f0a52..43fe5b98f 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -19,6 +19,7 @@ import { hostText, t } from './i18n.js' import { failureSentence } from '../shared/failure-copy.js' import { columnOf } from '../shared/task-board.js' import { lockedWindow } from './schedules.js' +import { readOnlyLine } from './read-only.js' // Done (and a failed task) ages out after 7 days. export const KEEP_MS = 7 * 86_400_000 @@ -661,7 +662,7 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f h('div', { className: 'bt-mem-body bt-inbox-body' }, // #2284: one sentence at the top of the dialog, in the host's own words, so a window that // draws no write says why instead of leaving the reads silent about it. - ...(locked ? [h('p', { key: 'readonly', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []), + ...(locked ? [readOnlyLine(h, roster, actions, { key: 'readonly' })] : []), h('div', { className: 'bt-inbox-feedback', role: 'status', 'aria-live': 'polite' }, feedback), GROUPS.map(group => { // #2050: the model keeps every finished row, so the group can draw the page's remainder diff --git a/core/src/client/memory-panel.js b/core/src/client/memory-panel.js index 7010a0fc9..aa13c7401 100644 --- a/core/src/client/memory-panel.js +++ b/core/src/client/memory-panel.js @@ -8,6 +8,7 @@ import { dateLocale, hostText, t } from './i18n.js' import { memoryBlockedNotice, memoryEmptyNotice } from './memory-empty.js' import { summaryBlocks } from './text.js' import { useLinger } from './motion.js' +import { readOnlyLine } from './read-only.js' const POLL_MS = 1500 const MENU_EXIT_MS = 120 @@ -321,7 +322,7 @@ export function MemoryDialog({ bot, actions, roster, leaving = false }) { h('button', { type: 'button', role: 'menuitem', disabled: count === 0 || view?.summarizing, onClick: regenerate }, h('span', { className: 'bt-menu-label' }, t('Regenerate summary')))) : null), h('div', { className: 'bt-mem-body' }, - locked ? h('p', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, + readOnlyLine(h, roster, actions), error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, hostText(error)) : null, view === null ? (error ? null : h(Skeleton)) : page === 'entries' ? h(Entries, { bot, view, remove: locked ? null : remove }) diff --git a/core/src/client/read-only.js b/core/src/client/read-only.js new file mode 100644 index 000000000..5774f8592 --- /dev/null +++ b/core/src/client/read-only.js @@ -0,0 +1,29 @@ +// #2502: the host's sentence tells the reader to close the other window and reload. The sidebar +// (#2247) and the refusal toast (#2265) grew that entry; the Settings header, the drawer, the +// Inbox and the Memory panel kept the sentence with nothing to answer it, and inside the Inbox +// modal the sidebar's own button cannot be reached at all. This is the shared piece those +// surfaces draw: the host's own words under `role="status"`, and one Reload outside that node (a +// live region must stay text), running the same `actions.reloadTeam` read the sidebar runs — never +// a page reload, which would drop the composer. +import { hostText, t } from './i18n.js' + +// A sentence of nothing but whitespace says nothing, so it draws nothing. Read the way the +// sidebar reads it (#2247), so the two surfaces cannot disagree about which window is locked. +export const readOnlySaid = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' + +// `reload: false` keeps the sentence and drops the control, for a view that already carries one: +// one Reload per screen, on the topmost sentence. The caller passes `h` rather than this module +// importing react: one more react import would renumber every module's binding in the generated +// `client.js`, turning a five-line change into a two-thousand-line diff of the bundle. +export function readOnlyLine(h, roster, actions, { key, className, style, reload = true } = {}) { + if (!readOnlySaid(roster)) return null + return h('div', { key, className: ['bt-readonly-line', className].filter(Boolean).join(' '), style }, + h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)), + reload + ? h('button', { + type: 'button', + className: 'bt-read-only-reload', + onClick: () => { void Promise.resolve(actions?.reloadTeam?.()).catch(error => actions?.reportError?.(error, 'reload')) }, + }, t('Reload')) + : null) +} diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 7ee72d42d..0c7535578 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -21,7 +21,8 @@ import { SchedulesPage, lockedWindow } from './schedules.js' import { FeedbackIcon, FeedbackPage } from './feedback.js' import { FolderMenu } from './folder-menu.js' import { AboutPage } from './about.js' -import { hostText, t } from './i18n.js' +import { t } from './i18n.js' +import { readOnlyLine } from './read-only.js' const capitalize = word => word[0].toUpperCase() + word.slice(1) @@ -258,9 +259,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = // A window that holds the team read-only still opens every page here, so the header says // why nothing can be changed, in the host's own sentence and above the refusal below it: // the standing state first, the answer to one pressed control closest to that control. - typeof roster?.readOnly === 'string' && roster.readOnly.trim() - ? h('div', { className: 'bt-hint bt-read-only', role: 'status', style: { padding: '0 20px 8px', textAlign: 'left' } }, hostText(roster.readOnly)) - : null, + readOnlyLine(h, roster, actions, { key: 'readonly', className: 'bt-readonly-line-head' }), shown === 'usage' ? null : errorLine, shown === 'usage' ? null : h('div', { key: pageKey, className: 'bt-settings-scroll bt-settings-page', 'data-move': last.current.move }, shown === 'general' ? h(GeneralPage, { roster, actions, run }) : null, diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 92cd88c29..18ead13b3 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -309,6 +309,13 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} +/* #2502: the host sentence and its Reload entry share one row; the sidebar's own rule sizes + the button, this only places it beside the sentence instead of under it. */ +.bt-readonly-line{display:flex;align-items:center;gap:8px} +.bt-readonly-line>.bt-hint{flex:1 1 auto;min-width:0} +.bt-readonly-line>.bt-read-only-reload{flex:none;margin:0} +.bt-readonly-line-head{padding:0 20px 8px} +.bt-readonly-line-head>.bt-hint{padding:0;text-align:left} .bt-nc-spacer{flex:1} .bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} .bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} diff --git a/core/test/details-bot-settings-readonly.test.js b/core/test/details-bot-settings-readonly.test.js index ae5d0a128..141803b6f 100644 --- a/core/test/details-bot-settings-readonly.test.js +++ b/core/test/details-bot-settings-readonly.test.js @@ -102,7 +102,10 @@ const ownText = (node) => { const bodyOf = (nodes) => nodes.find(node => node.props?.className === 'bt-drawer-body bt-set-body') // A write entry is anything in the Bot settings body a person can change something with: a text // field or a button. The drawer's own Back/Close buttons live in the head, outside this body. -const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type)) +// The read-only Reload (#2502) is the one button in this body that is not a write: it asks the +// client to read the team again, and the issue gives it to the sentence it sits beside. +const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type) + && !String(node.props?.className ?? '').split(' ').includes('bt-read-only-reload')) test('a read-only window draws no Bot-settings write entry and says why in the host\'s words', () => { const nodes = tree(rosterWith(SENTENCE)) diff --git a/core/test/readonly-reload-lines.test.js b/core/test/readonly-reload-lines.test.js new file mode 100644 index 000000000..c5248a2ce --- /dev/null +++ b/core/test/readonly-reload-lines.test.js @@ -0,0 +1,246 @@ +// #2502: the host's sentence tells the reader to close the other window and reload. The sidebar +// (#2247) and the refusal toast (#2265) grew that entry; the Settings header, the drawer, the +// Inbox and the Memory panel kept the sentence with nothing to answer it — and inside the Inbox +// modal the sidebar's own button cannot be reached at all. Each of those four surfaces now draws +// one Reload beside the sentence, through one shared piece, so the copy is the host's own words +// and the control is one label rather than four copies. The button sits outside `role="status"` +// so the live region stays text, as in the sidebar, and a writable window draws neither. +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 { DetailsDrawer } from './src/client/details-drawer.js'", + "export { InboxDialog } from './src/client/inbox.js'", + "export { MemoryDialog } from './src/client/memory-panel.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) } }) +// A hook runtime that survives a re-render: the memory dialog draws its skeleton first and its +// view after an effect, and the same dialog is drawn again in the writable cases. +let slots = [] +let index = 0 +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn }, + useLayoutEffect() {}, + useId: () => 'id', + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + 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, DetailsDrawer, InboxDialog, MemoryDialog } = 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 room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief'], mode: 'admin', notice: '', named: true, workDir: '/work/ops', participants: [], presentedParticipants: [], viewerRole: 'owner' } +const base = { + bots: [chief], byId: { chief }, rooms: [room], roomsById: { r1: room }, mainBotIds: ['chief'], mainBotId: 'chief', + prefs: { theme: 'deepseek' }, models: [], connectors: [], capabilities: [], secrets: [], humans: [], remoteBots: [], + memory: true, questions: {}, exchangeTurns: {}, +} +const baseRoom = { ...base, self: { memberId: 'm1' } } +const locked = { ...base, readOnly: SENTENCE } +const lockedRoom = { ...baseRoom, readOnly: SENTENCE } + +// A failed reload must reach the client's error channel; a write called here would be a defect. +const actions = (over = {}) => ({ + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, openMemory() {}, + notify() {}, showDetails() {}, call: () => Promise.resolve({}), memoryView: () => Promise.resolve(null), + schedules: () => Promise.resolve({ available: true, tasks: [] }), + reloadTeam: () => Promise.resolve(), reportError() {}, + ...over, +}) + +// The dialog's own tree holds component elements; draw them the way the renderer would, with one +// hook index continuing through the tree, and stop at the widgets that only draw children. +const draw = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) draw(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 8) { + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); slots = saved; index = savedIndex; return out } + slots = saved + index = savedIndex + return draw(rendered, out, depth + 1) + } + out.push(node) + draw(node.props?.children, out, depth) + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} +const byClass = (nodes, name) => nodes.filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name)) +const sentenceNodes = nodes => byClass(nodes, 'bt-read-only') +const reloadButtons = nodes => byClass(nodes, 'bt-read-only-reload') + +const mountSettings = (roster, page = 'general', id = null, acts = actions()) => { + slots = [] + index = 0 + return draw(SettingsDialog({ page, id, roster, actions: acts, useUpdate: () => ({}) })) +} +const mountDrawer = (roster, startTab = 'details', sessionId = 'chief', acts = actions()) => { + slots = [] + index = 0 + const empty = selector => selector(new Map()) + return draw(DetailsDrawer({ + sessionId, startTab, hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: empty, + useActivity: empty, useWork: empty, useBrowserTabs: empty, useBrowserPages: empty, + })) +} +const mountInbox = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(InboxDialog({ roster, actions: acts, useWork: selector => selector({}), useSessions: selector => selector({ byId: {}, projectionsBySession: {} }) })) +} +const mountMemory = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(MemoryDialog({ bot: chief, roster, actions: acts })) +} + +const PAGES = ['general', 'bots', 'groups', 'projects', 'connectors', 'capabilities', 'schedules', 'workflows', 'usage', 'secrets', 'feedback', 'about'] + +// Every surface the issue names, with the mount that draws it. +const SURFACES = [ + { name: 'Settings header', mount: roster => mountSettings(roster) }, + { name: 'drawer: Bot overview', mount: roster => mountDrawer(roster) }, + { name: 'drawer: Bot settings', mount: roster => mountDrawer(roster, 'settings') }, + { name: 'drawer: group overview', mount: roster => mountDrawer(roster, 'details', 'r1') }, + { name: 'drawer: group settings', mount: roster => mountDrawer(roster, 'settings', 'r1') }, + { name: 'Inbox dialog', mount: roster => mountInbox(roster) }, + { name: 'Memory panel', mount: roster => mountMemory(roster) }, +] + +test('the Settings header offers one Reload beside the host sentence on every page', () => { + for (const page of PAGES) { + const nodes = mountSettings(locked, page) + assert.equal(sentenceNodes(nodes).length, 1, `${page}: the host's sentence is still drawn once`) + assert.equal(reloadButtons(nodes).length, 1, `${page}: the header offers one Reload entry`) + assert.equal(reloadButtons(nodes)[0].type, 'button', `${page}: it is a button`) + assert.equal(reloadButtons(nodes)[0].props.type, 'button', `${page}: it does not submit a form`) + assert.equal(text(reloadButtons(nodes)[0]).trim(), 'Reload', `${page}: its label is the shared copy`) + } +}) + +test('each drawer surface offers exactly one Reload, never two', () => { + const surfaces = SURFACES.filter(surface => surface.name.startsWith('drawer')) + for (const surface of surfaces) { + const nodes = surface.mount(surface.name.includes('group') ? lockedRoom : locked) + assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: one sentence`) + assert.equal(reloadButtons(nodes).length, 1, `${surface.name}: one Reload, not two`) + } +}) + +test('the Inbox and the Memory panel each offer one Reload — their own way out of the modal', () => { + for (const surface of SURFACES.filter(entry => !entry.name.startsWith('drawer') && entry.name !== 'Settings header')) { + const nodes = surface.mount(locked) + assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: the sentence stays once`) + assert.equal(reloadButtons(nodes).length, 1, `${surface.name}: one Reload entry`) + } +}) + +test('one shared piece: the sentence is the host sentence, and the control sits outside the live region', () => { + for (const surface of SURFACES) { + const nodes = surface.mount(surface.name.includes('group') ? lockedRoom : locked) + const [sentence] = sentenceNodes(nodes) + const [button] = reloadButtons(nodes) + assert.equal(sentence.props.role, 'status', `${surface.name}: the sentence keeps the status role`) + assert.equal(text(sentence), SENTENCE, `${surface.name}: the words are the host's own, byte for byte`) + assert.equal(draw(sentence).includes(button), false, `${surface.name}: the live region stays text-only`) + assert.ok(draw(nodes).indexOf(sentence) < draw(nodes).indexOf(button), `${surface.name}: the sentence is read first`) + } +}) + +test('a press runs the client\'s own team read, once, and sends no write', async () => { + for (const surface of SURFACES) { + let reads = 0 + const writes = [] + const acts = actions({ + reloadTeam: () => { reads += 1; return Promise.resolve() }, + updateBot: (...args) => { writes.push(['updateBot', ...args]); return Promise.resolve() }, + updateRoom: (...args) => { writes.push(['updateRoom', ...args]); return Promise.resolve() }, + setPrefs: (...args) => { writes.push(['setPrefs', ...args]); return Promise.resolve() }, + }) + const nodes = mountFor(surface, surface.name.includes('group') ? lockedRoom : locked, acts) + const [button] = reloadButtons(nodes) + assert.ok(button, `${surface.name}: the entry is drawn`) + button.props.onClick() + await Promise.resolve() + await Promise.resolve() + assert.equal(reads, 1, `${surface.name}: one press reads the team once`) + assert.deepEqual(writes, [], `${surface.name}: the press sends no write`) + } +}) + +const mountFor = (surface, roster, acts) => { + if (surface.name === 'Settings header') return mountSettings(roster, 'general', null, acts) + if (surface.name === 'Inbox dialog') return mountInbox(roster, acts) + if (surface.name === 'Memory panel') return mountMemory(roster, acts) + return mountDrawer(roster, surface.name.includes('settings') ? 'settings' : 'details', surface.name.includes('group') ? 'r1' : 'chief', acts) +} + +test('a refused reload is reported through the client\'s error channel, not left unhandled', async () => { + const failure = new Error('network error: Failed to fetch') + const seen = [] + const nodes = mountSettings(locked, 'general', null, actions({ reloadTeam: () => Promise.reject(failure), reportError: (error, where) => seen.push([error, where]) })) + const [button] = reloadButtons(nodes) + button.props.onClick() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + assert.deepEqual(seen, [[failure, 'reload']]) +}) + +test('a writable window, or a host sentence of only whitespace, draws neither sentence nor entry', () => { + for (const surface of SURFACES) { + for (const readOnly of [undefined, '', ' ', '\n\t ']) { + const roster = { ...(surface.name.includes('group') ? baseRoom : base), ...(readOnly === undefined ? {} : { readOnly }) } + const nodes = mountFor(surface, roster, actions()) + assert.equal(sentenceNodes(nodes).length, 0, `${surface.name}, ${JSON.stringify(readOnly)}: no sentence`) + assert.equal(reloadButtons(nodes).length, 0, `${surface.name}, ${JSON.stringify(readOnly)}: no entry`) + } + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 5543768ac..b7ae613b7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -15,7 +15,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - The chat's `ask_user` card (`questions.js`) answers where it is drawn: one option answers a single-select card at once, a multi-select card toggles its rows and **Submit** sends the set, and the × dismisses it; the same card waits in the dock above the composer when its call is not on screen. In a window that holds the team read-only the card keeps the question, its rows and the host's sentence (`roster.readOnly`, read through `lockedWindow` from `schedules.js`) and draws none of those controls — no option button, **Submit**, **Skip**, × or own-answer field, and no form to submit. Each of them reached a write the host refuses (`answer-question`, `dismiss-question`), and the refusal was invisible: a pressed answer marked the card answered and the next poll said nothing, while the × hid the card until that poll brought it back. Tests: `core/test/questions-readonly.test.js`. - A shared room's overview (`details-drawer.js` `SharedRoomApprovals`) draws its **Waiting for your approval** card in a read-only window (`lockedWindow`, `schedules.js`) with no **Approve**/**Reject**: a decision is `social/decide-approval`, a write the host refuses there, so the two buttons could only earn `bot/read-only` after the click. The card keeps its headline, its count and each request's own text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`), the shape the drawer's own Settings view already used. Since #2409 each row also carries the wait's own age beside its text (` · 12 min`, `relativeShort()`), parsed from the plugin view's `createdAt` and left off when that field is absent, so two requests of different ages read apart. A decision the host still refuses — a writable window, or a room with several members answering `bot/blocked-shared` — writes that reason on the card in one `role="alert"` line (`bt-error`) instead of vanishing, which is what the component's `try`/`finally` with no `catch` did (#2381). Tests: `core/test/room-approvals-readonly.test.js`, `core/test/approval-age.test.js`. -- Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`. +- Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`. Since #2502 that sentence and entry are one shared piece: `client/read-only.js` exports `readOnlyLine(h, roster, actions, { key, className, style, reload })` and `readOnlySaid`, where the first argument is the renderer `h` itself, passed by the caller so the new module never imports react and the generated bundle keeps its bindings, and the Settings header, the drawer's three hint sites (`details-drawer.js` Bot overview, Bot settings view and group overview), the Inbox dialog and the Memory panel draw them instead of their own copies. The sentence stays `bt-hint bt-read-only` under `role="status"`, the button sits outside that node, and a press runs the same `actions.reloadTeam` read with `reportError(error, 'reload')` on failure; one view draws at most one entry, and `reload: false` keeps a sentence without the control so a second hint can never grow a second button. A writable window, and a sentence of only whitespace, draw neither. Tests: `core/test/readonly-reload-lines.test.js`. - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). - Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index c0128e5ac..d9686b970 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2016,13 +2016,14 @@ diff --git a/src/client/member-status.js b/src/client/member-status.js diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js --- a/src/client/memory-panel.js +++ b/src/client/memory-panel.js -@@ -5,44 +5,75 @@ import { createElement as h, Fragment, useCallback, useEffect, useRef, useState +@@ -5,44 +5,76 @@ import { createElement as h, Fragment, useCallback, useEffect, useRef, useState import { useEscape } from './overlay-hooks.js' import { ArrowUpIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CloseIcon, DotsIcon, MemoryIcon, TrashIcon } from './icons.js' import { dateLocale, hostText, t } from './i18n.js' +import { memoryBlockedNotice, memoryEmptyNotice } from './memory-empty.js' import { summaryBlocks } from './text.js' import { useLinger } from './motion.js' ++import { readOnlyLine } from './read-only.js' const POLL_MS = 1500 const MENU_EXIT_MS = 120 @@ -2168,7 +2169,7 @@ diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js h('button', { type: 'button', role: 'menuitem', disabled: count === 0 || view?.summarizing, onClick: regenerate }, h('span', { className: 'bt-menu-label' }, t('Regenerate summary')))) : null), h('div', { className: 'bt-mem-body' }, -+ locked ? h('p', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, ++ readOnlyLine(h, roster, actions), error ? h('div', { className: 'bt-error', role: 'alert', style: { padding: 0 } }, hostText(error)) : null, view === null ? (error ? null : h(Skeleton)) - : page === 'entries' ? h(Entries, { bot, view, remove }) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index c78a6243f..53d6b1650 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -3411,7 +3411,7 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -280,7 +280,9 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -280,9 +280,18 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3421,7 +3421,16 @@ diff --git a/src/client/styles.js b/src/client/styles.js +.bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} ++/* #2502: the host sentence and its Reload entry share one row; the sidebar's own rule sizes ++ the button, this only places it beside the sentence instead of under it. */ ++.bt-readonly-line{display:flex;align-items:center;gap:8px} ++.bt-readonly-line>.bt-hint{flex:1 1 auto;min-width:0} ++.bt-readonly-line>.bt-read-only-reload{flex:none;margin:0} ++.bt-readonly-line-head{padding:0 20px 8px} ++.bt-readonly-line-head>.bt-hint{padding:0;text-align:left} .bt-nc-spacer{flex:1} + .bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} + .bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} @@ -309,6 +309,10 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 3aaeee155..5b00bbb85 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2205,7 +2205,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,1039 +1624,3139 @@ function usageOf(list, ids) { +@@ -1624,1039 +1624,3146 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -5661,6 +5661,13 @@ diff --git a/client.js b/client.js +.bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} +.bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} +.bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} ++/* #2502: the host sentence and its Reload entry share one row; the sidebar's own rule sizes ++ the button, this only places it beside the sentence instead of under it. */ ++.bt-readonly-line{display:flex;align-items:center;gap:8px} ++.bt-readonly-line>.bt-hint{flex:1 1 auto;min-width:0} ++.bt-readonly-line>.bt-read-only-reload{flex:none;margin:0} ++.bt-readonly-line-head{padding:0 20px 8px} ++.bt-readonly-line-head>.bt-hint{padding:0;text-align:left} +.bt-nc-spacer{flex:1} +.bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} +.bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} @@ -6352,7 +6359,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,6514 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2833 +2676,6532 @@ 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} } @@ -7410,7 +7417,7 @@ diff --git a/client.js b/client.js +} +function HeldReplyActions({ held, actions, roster }) { + const locked = lockedWindow(roster); -+ const readOnlyLine = () => (0, import_react10.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)); ++ const readOnlyLine2 = () => (0, import_react10.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)); + const custom = useInlineEntry(held?.by); + const [busy, setBusy] = (0, import_react10.useState)(false); + const [error, setError] = (0, import_react10.useState)(""); @@ -7440,7 +7447,7 @@ diff --git a/client.js b/client.js + (0, import_react10.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), + // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a + // different field elsewhere. A failure that can never succeed draws no button on any surface. -+ failed && canRetry(held) ? locked ? readOnlyLine() : (0, import_react10.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, ++ failed && canRetry(held) ? locked ? readOnlyLine2() : (0, import_react10.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react10.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); + } @@ -7453,7 +7460,7 @@ diff --git a/client.js b/client.js + (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), + (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + reason ? (0, import_react10.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, -+ locked ? readOnlyLine() : (0, import_react10.createElement)( ++ locked ? readOnlyLine2() : (0, import_react10.createElement)( + "div", + { className: "bt-flybot-approval-actions" }, + (0, import_react10.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), @@ -8302,6 +8309,9 @@ 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 + ); ++} ++function Shimmer({ children }) { ++ return (0, import_react11.createElement)("span", { className: "bt-shimmer" }, children); } -var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; -var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; @@ -8311,9 +8321,6 @@ diff --git a/client.js b/client.js - 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); -+} +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"] @@ -8530,6 +8537,16 @@ diff --git a/client.js b/client.js - "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 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 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" }) + }, [done, sessionId, callId]); + let replies = live.replies; + if (done) { @@ -8625,8 +8642,22 @@ diff --git a/client.js b/client.js + }) ); } --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 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; +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) { @@ -8642,11 +8673,18 @@ diff --git a/client.js b/client.js + visit(root); + return calls; } --function MainStar() { +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; - return (0, import_react2.createElement)( -- "svg", -- { 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" }) +- "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 useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); @@ -8657,15 +8695,13 @@ diff --git a/client.js b/client.js + 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 clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -8676,35 +8712,7 @@ 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 })); @@ -8837,9 +8845,6 @@ diff --git a/client.js b/client.js - (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()) { @@ -8847,6 +8852,9 @@ diff --git a/client.js b/client.js + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } +-function 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", @@ -9025,10 +9033,7 @@ diff --git a/client.js b/client.js + const check = () => { + frame = 0; + if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); + }); @@ -9037,7 +9042,10 @@ 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; } @@ -10114,15 +10122,8 @@ diff --git a/client.js b/client.js + (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 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) : []; @@ -10217,6 +10218,31 @@ diff --git a/client.js b/client.js + return Math.max(0, Math.min(100, Math.round(value))); +} + ++// src/client/read-only.js ++var readOnlySaid = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; ++function readOnlyLine(h50, roster, actions, { key, className, style, reload = true } = {}) { ++ if (!readOnlySaid(roster)) return null; ++ return h50( ++ "div", ++ { key, className: ["bt-readonly-line", className].filter(Boolean).join(" "), style }, ++ h50("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)), ++ reload ? h50("button", { ++ type: "button", ++ className: "bt-read-only-reload", ++ onClick: () => { ++ void Promise.resolve(actions?.reloadTeam?.()).catch((error) => actions?.reportError?.(error, "reload")); ++ } ++ }, t("Reload")) : null + ); + } +-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(); ++ +// src/client/inbox.js +var import_react15 = require("react"); +var KEEP_MS = 7 * 864e5; @@ -10989,7 +11015,7 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)( + "div", + { className: "bt-mem-body bt-inbox-body" }, -+ ...locked ? [(0, import_react15.createElement)("p", { key: "readonly", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly))] : [], ++ ...locked ? [readOnlyLine(import_react15.createElement, roster, actions, { key: "readonly" })] : [], + (0, import_react15.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), + GROUPS.map((group) => { + const settled = group.id === "done" ? model.doneAll ?? groups.done : null; @@ -12994,7 +13020,7 @@ diff --git a/client.js b/client.js + return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); + }, [roster, updated]); + const main = roster.byId[roster.mainBotId]; -+ const readOnlySaid = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; ++ const readOnlySaid2 = typeof roster.readOnly === "string" && roster.readOnly.trim() !== ""; + const readOnlyReload = (0, import_react19.createElement)("button", { + type: "button", + className: "bt-read-only-reload", @@ -13076,10 +13102,10 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react19.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react19.createElement)(PlusIcon)) + ), -+ wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -+ wide && readOnlySaid ? readOnlyReload : null, -+ !wide && readOnlySaid ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, -+ !wide && readOnlySaid ? readOnlyReload : null, ++ wide && readOnlySaid2 ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ wide && readOnlySaid2 ? readOnlyReload : null, ++ !wide && readOnlySaid2 ? (0, import_react19.createElement)("div", { className: "bt-hint bt-read-only", role: "status", title: hostText(roster.readOnly), "aria-label": hostText(roster.readOnly) }, hostText(roster.readOnly)) : null, ++ !wide && readOnlySaid2 ? readOnlyReload : null, + (0, import_react19.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), + (0, import_react19.createElement)(KanbanRow, { wide, useUi, actions }), + (0, import_react19.createElement)(ProjectsNav, { wide }), @@ -18200,7 +18226,7 @@ diff --git a/client.js b/client.js + (0, import_react28.createElement)( + "div", + { className: "bt-mem-body" }, -+ locked ? (0, import_react28.createElement)("p", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ readOnlyLine(import_react28.createElement, roster, actions), + error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, + view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove: locked ? null : remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate }) + ), @@ -24073,7 +24099,7 @@ diff --git a/client.js b/client.js + // A window that holds the team read-only still opens every page here, so the header says + // why nothing can be changed, in the host's own sentence and above the refusal below it: + // the standing state first, the answer to one pressed control closest to that control. -+ typeof roster?.readOnly === "string" && roster.readOnly.trim() ? (0, import_react42.createElement)("div", { className: "bt-hint bt-read-only", role: "status", style: { padding: "0 20px 8px", textAlign: "left" } }, hostText(roster.readOnly)) : null, ++ readOnlyLine(import_react42.createElement, roster, actions, { key: "readonly", className: "bt-readonly-line-head" }), + shown === "usage" ? null : errorLine, + shown === "usage" ? null : (0, import_react42.createElement)( + "div", @@ -25051,7 +25077,7 @@ diff --git a/client.js b/client.js + // P-544: the group overview's writes are gone in a locked window, so it says why once, above + // the reads it still draws. The approval card used to carry its own copy (#2381); one page, + // one sentence. -+ locked ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ readOnlyLine(import_react44.createElement, roster, actions, { key: "read-only" }), + (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react44.createElement)( + "div", @@ -25229,7 +25255,7 @@ diff --git a/client.js b/client.js - bot ? (0, import_react31.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react31.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) + // #2387: the lock is the window's, not the Bot's — a read-only window draws the host's sentence + // in place of a group's form too, exactly as it already did for a Bot's. -+ lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ++ lockedWindow(roster) ? readOnlyLine(import_react44.createElement, roster, actions) : bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } @@ -25331,7 +25357,7 @@ diff --git a/client.js b/client.js + // locked window refuses every one of them. Say so once at the top and draw the read half only, + // which is the values themselves (P-547) — the name above is already text, and the settings + // gear still opens the page that explains the window. -+ lockedWindow(roster) ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ lockedWindow(roster) ? readOnlyLine(import_react44.createElement, roster, actions, { key: "read-only" }) : null, + lockedWindow(roster) ? (0, import_react44.createElement)(ReadOnlyOverview, { key: "read", bot, roster }) : null, + lockedWindow(roster) ? null : (0, import_react44.createElement)( "div", diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 337dcb06e..36779d80a 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -110,7 +110,7 @@ diff --git a/src/client/composer.js b/src/client/composer.js diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js -@@ -1,31 +1,22 @@ +@@ -1,31 +1,23 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { isMainOf } from './sources.js' -import { HEADER, currentPart, stripHeader, usageOf } from './text.js' @@ -131,7 +131,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js +import { RoomSchedules, ScheduleSummary, lockedWindow } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' --import { t } from './i18n.js' + import { t } from './i18n.js' - -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline @@ -144,10 +144,10 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - } - return lines.join('\n\n') -} -+import { hostText, t } from './i18n.js' +import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' +import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' +import { call } from './api.js' ++import { readOnlyLine } from './read-only.js' const TAB_LABELS = { details: 'Details', browser: 'Browser' } @@ -285,7 +285,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + // P-544: the group overview's writes are gone in a locked window, so it says why once, above + // the reads it still draws. The approval card used to carry its own copy (#2381); one page, + // one sentence. -+ locked ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, ++ readOnlyLine(h, roster, actions, { key: 'read-only' }), + h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), + invited && room.sharedFrom && !face + ? h('div', { key: 'from', className: 'bt-shared-from' }, @@ -381,7 +381,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + // #2387: the lock is the window's, not the Bot's — a read-only window draws the host's sentence + // in place of a group's form too, exactly as it already did for a Bot's. + lockedWindow(roster) -+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) ++ ? readOnlyLine(h, roster, actions) + : bot ? h(BotSettingsForm, { bot, roster, actions, run }) : h(GroupSettingsForm, { room, roster, actions, run, onDeleted: close })), ] } @@ -464,7 +464,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + // locked window refuses every one of them. Say so once at the top and draw the read half only, + // which is the values themselves (P-547) — the name above is already text, and the settings + // gear still opens the page that explains the window. -+ lockedWindow(roster) ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, ++ lockedWindow(roster) ? readOnlyLine(h, roster, actions, { key: 'read-only' }) : null, + lockedWindow(roster) ? h(ReadOnlyOverview, { key: 'read', bot, roster }) : null, + lockedWindow(roster) ? null : h('div', { key: 'model', className: 'bt-drawer-model' }, h('div', { className: 'bt-section-title' }, t('Model')), diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index a1d236686..ecc823bf6 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -280,7 +280,7 @@ diff --git a/src/client/secret-card.js b/src/client/secret-card.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -2,29 +2,33 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're +@@ -2,29 +2,34 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're import { ACCENT_INKS, INKS } from './inks.js' import { isMainOf } from './sources.js' import { allThemes } from './themes.js' @@ -308,8 +308,8 @@ diff --git a/src/client/settings.js b/src/client/settings.js import { FeedbackIcon, FeedbackPage } from './feedback.js' +import { FolderMenu } from './folder-menu.js' import { AboutPage } from './about.js' --import { t } from './i18n.js' -+import { hostText, t } from './i18n.js' + import { t } from './i18n.js' ++import { readOnlyLine } from './read-only.js' const capitalize = word => word[0].toUpperCase() + word.slice(1) @@ -462,7 +462,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null const nav = useGlide(shown, { axis: 'y' }) // A page enters from the right when it is one level in, from the left when it comes -@@ -178,38 +178,162 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -178,38 +178,160 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = // rendered state off screen (content-visibility). const [usageOpened, setUsageOpened] = useState(shown === 'usage') if (shown === 'usage' && !usageOpened) setUsageOpened(true) @@ -498,9 +498,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js + // A window that holds the team read-only still opens every page here, so the header says + // why nothing can be changed, in the host's own sentence and above the refusal below it: + // the standing state first, the answer to one pressed control closest to that control. -+ typeof roster?.readOnly === 'string' && roster.readOnly.trim() -+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status', style: { padding: '0 20px 8px', textAlign: 'left' } }, hostText(roster.readOnly)) -+ : null, ++ readOnlyLine(h, roster, actions, { key: 'readonly', className: 'bt-readonly-line-head' }), + shown === 'usage' ? null : errorLine, shown === 'usage' ? null : h('div', { key: pageKey, className: 'bt-settings-scroll bt-settings-page', 'data-move': last.current.move }, - errorLine, diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index 8a81f0a52..43fe5b98f 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -19,6 +19,7 @@ import { hostText, t } from './i18n.js' import { failureSentence } from '../shared/failure-copy.js' import { columnOf } from '../shared/task-board.js' import { lockedWindow } from './schedules.js' +import { readOnlyLine } from './read-only.js' // Done (and a failed task) ages out after 7 days. export const KEEP_MS = 7 * 86_400_000 @@ -661,7 +662,7 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f h('div', { className: 'bt-mem-body bt-inbox-body' }, // #2284: one sentence at the top of the dialog, in the host's own words, so a window that // draws no write says why instead of leaving the reads silent about it. - ...(locked ? [h('p', { key: 'readonly', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))] : []), + ...(locked ? [readOnlyLine(h, roster, actions, { key: 'readonly' })] : []), h('div', { className: 'bt-inbox-feedback', role: 'status', 'aria-live': 'polite' }, feedback), GROUPS.map(group => { // #2050: the model keeps every finished row, so the group can draw the page's remainder diff --git a/patches/ds-bot/files/src/client/read-only.js b/patches/ds-bot/files/src/client/read-only.js new file mode 100644 index 000000000..5774f8592 --- /dev/null +++ b/patches/ds-bot/files/src/client/read-only.js @@ -0,0 +1,29 @@ +// #2502: the host's sentence tells the reader to close the other window and reload. The sidebar +// (#2247) and the refusal toast (#2265) grew that entry; the Settings header, the drawer, the +// Inbox and the Memory panel kept the sentence with nothing to answer it, and inside the Inbox +// modal the sidebar's own button cannot be reached at all. This is the shared piece those +// surfaces draw: the host's own words under `role="status"`, and one Reload outside that node (a +// live region must stay text), running the same `actions.reloadTeam` read the sidebar runs — never +// a page reload, which would drop the composer. +import { hostText, t } from './i18n.js' + +// A sentence of nothing but whitespace says nothing, so it draws nothing. Read the way the +// sidebar reads it (#2247), so the two surfaces cannot disagree about which window is locked. +export const readOnlySaid = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' + +// `reload: false` keeps the sentence and drops the control, for a view that already carries one: +// one Reload per screen, on the topmost sentence. The caller passes `h` rather than this module +// importing react: one more react import would renumber every module's binding in the generated +// `client.js`, turning a five-line change into a two-thousand-line diff of the bundle. +export function readOnlyLine(h, roster, actions, { key, className, style, reload = true } = {}) { + if (!readOnlySaid(roster)) return null + return h('div', { key, className: ['bt-readonly-line', className].filter(Boolean).join(' '), style }, + h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)), + reload + ? h('button', { + type: 'button', + className: 'bt-read-only-reload', + onClick: () => { void Promise.resolve(actions?.reloadTeam?.()).catch(error => actions?.reportError?.(error, 'reload')) }, + }, t('Reload')) + : null) +} diff --git a/patches/ds-bot/files/test/details-bot-settings-readonly.test.js b/patches/ds-bot/files/test/details-bot-settings-readonly.test.js index ae5d0a128..141803b6f 100644 --- a/patches/ds-bot/files/test/details-bot-settings-readonly.test.js +++ b/patches/ds-bot/files/test/details-bot-settings-readonly.test.js @@ -102,7 +102,10 @@ const ownText = (node) => { const bodyOf = (nodes) => nodes.find(node => node.props?.className === 'bt-drawer-body bt-set-body') // A write entry is anything in the Bot settings body a person can change something with: a text // field or a button. The drawer's own Back/Close buttons live in the head, outside this body. -const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type)) +// The read-only Reload (#2502) is the one button in this body that is not a write: it asks the +// client to read the team again, and the issue gives it to the sentence it sits beside. +const writes = (nodes) => nodes.filter(node => ['input', 'textarea', 'select', 'button'].includes(node.type) + && !String(node.props?.className ?? '').split(' ').includes('bt-read-only-reload')) test('a read-only window draws no Bot-settings write entry and says why in the host\'s words', () => { const nodes = tree(rosterWith(SENTENCE)) diff --git a/patches/ds-bot/files/test/readonly-reload-lines.test.js b/patches/ds-bot/files/test/readonly-reload-lines.test.js new file mode 100644 index 000000000..c5248a2ce --- /dev/null +++ b/patches/ds-bot/files/test/readonly-reload-lines.test.js @@ -0,0 +1,246 @@ +// #2502: the host's sentence tells the reader to close the other window and reload. The sidebar +// (#2247) and the refusal toast (#2265) grew that entry; the Settings header, the drawer, the +// Inbox and the Memory panel kept the sentence with nothing to answer it — and inside the Inbox +// modal the sidebar's own button cannot be reached at all. Each of those four surfaces now draws +// one Reload beside the sentence, through one shared piece, so the copy is the host's own words +// and the control is one label rather than four copies. The button sits outside `role="status"` +// so the live region stays text, as in the sidebar, and a writable window draws neither. +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 { DetailsDrawer } from './src/client/details-drawer.js'", + "export { InboxDialog } from './src/client/inbox.js'", + "export { MemoryDialog } from './src/client/memory-panel.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) } }) +// A hook runtime that survives a re-render: the memory dialog draws its skeleton first and its +// view after an effect, and the same dialog is drawn again in the writable cases. +let slots = [] +let index = 0 +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn }, + useLayoutEffect() {}, + useId: () => 'id', + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {}, + 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, DetailsDrawer, InboxDialog, MemoryDialog } = 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 room = { id: 'r1', name: 'Ops', admin: 'chief', members: ['chief'], mode: 'admin', notice: '', named: true, workDir: '/work/ops', participants: [], presentedParticipants: [], viewerRole: 'owner' } +const base = { + bots: [chief], byId: { chief }, rooms: [room], roomsById: { r1: room }, mainBotIds: ['chief'], mainBotId: 'chief', + prefs: { theme: 'deepseek' }, models: [], connectors: [], capabilities: [], secrets: [], humans: [], remoteBots: [], + memory: true, questions: {}, exchangeTurns: {}, +} +const baseRoom = { ...base, self: { memberId: 'm1' } } +const locked = { ...base, readOnly: SENTENCE } +const lockedRoom = { ...baseRoom, readOnly: SENTENCE } + +// A failed reload must reach the client's error channel; a write called here would be a defect. +const actions = (over = {}) => ({ + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, openMemory() {}, + notify() {}, showDetails() {}, call: () => Promise.resolve({}), memoryView: () => Promise.resolve(null), + schedules: () => Promise.resolve({ available: true, tasks: [] }), + reloadTeam: () => Promise.resolve(), reportError() {}, + ...over, +}) + +// The dialog's own tree holds component elements; draw them the way the renderer would, with one +// hook index continuing through the tree, and stop at the widgets that only draw children. +const draw = (node, out = [], depth = 0) => { + if (Array.isArray(node)) { for (const item of node) draw(item, out, depth); return out } + if (!node || typeof node !== 'object') return out + if (typeof node.type === 'function' && depth < 8) { + const saved = slots + const savedIndex = index + slots = [] + index = 0 + let rendered + try { rendered = node.type({ ...node.props }) } catch { out.push(node); slots = saved; index = savedIndex; return out } + slots = saved + index = savedIndex + return draw(rendered, out, depth + 1) + } + out.push(node) + draw(node.props?.children, out, depth) + return out +} +const text = (node) => { + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (node && typeof node === 'object') return text(node.props?.children) + return '' +} +const byClass = (nodes, name) => nodes.filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name)) +const sentenceNodes = nodes => byClass(nodes, 'bt-read-only') +const reloadButtons = nodes => byClass(nodes, 'bt-read-only-reload') + +const mountSettings = (roster, page = 'general', id = null, acts = actions()) => { + slots = [] + index = 0 + return draw(SettingsDialog({ page, id, roster, actions: acts, useUpdate: () => ({}) })) +} +const mountDrawer = (roster, startTab = 'details', sessionId = 'chief', acts = actions()) => { + slots = [] + index = 0 + const empty = selector => selector(new Map()) + return draw(DetailsDrawer({ + sessionId, startTab, hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: empty, + useActivity: empty, useWork: empty, useBrowserTabs: empty, useBrowserPages: empty, + })) +} +const mountInbox = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(InboxDialog({ roster, actions: acts, useWork: selector => selector({}), useSessions: selector => selector({ byId: {}, projectionsBySession: {} }) })) +} +const mountMemory = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(MemoryDialog({ bot: chief, roster, actions: acts })) +} + +const PAGES = ['general', 'bots', 'groups', 'projects', 'connectors', 'capabilities', 'schedules', 'workflows', 'usage', 'secrets', 'feedback', 'about'] + +// Every surface the issue names, with the mount that draws it. +const SURFACES = [ + { name: 'Settings header', mount: roster => mountSettings(roster) }, + { name: 'drawer: Bot overview', mount: roster => mountDrawer(roster) }, + { name: 'drawer: Bot settings', mount: roster => mountDrawer(roster, 'settings') }, + { name: 'drawer: group overview', mount: roster => mountDrawer(roster, 'details', 'r1') }, + { name: 'drawer: group settings', mount: roster => mountDrawer(roster, 'settings', 'r1') }, + { name: 'Inbox dialog', mount: roster => mountInbox(roster) }, + { name: 'Memory panel', mount: roster => mountMemory(roster) }, +] + +test('the Settings header offers one Reload beside the host sentence on every page', () => { + for (const page of PAGES) { + const nodes = mountSettings(locked, page) + assert.equal(sentenceNodes(nodes).length, 1, `${page}: the host's sentence is still drawn once`) + assert.equal(reloadButtons(nodes).length, 1, `${page}: the header offers one Reload entry`) + assert.equal(reloadButtons(nodes)[0].type, 'button', `${page}: it is a button`) + assert.equal(reloadButtons(nodes)[0].props.type, 'button', `${page}: it does not submit a form`) + assert.equal(text(reloadButtons(nodes)[0]).trim(), 'Reload', `${page}: its label is the shared copy`) + } +}) + +test('each drawer surface offers exactly one Reload, never two', () => { + const surfaces = SURFACES.filter(surface => surface.name.startsWith('drawer')) + for (const surface of surfaces) { + const nodes = surface.mount(surface.name.includes('group') ? lockedRoom : locked) + assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: one sentence`) + assert.equal(reloadButtons(nodes).length, 1, `${surface.name}: one Reload, not two`) + } +}) + +test('the Inbox and the Memory panel each offer one Reload — their own way out of the modal', () => { + for (const surface of SURFACES.filter(entry => !entry.name.startsWith('drawer') && entry.name !== 'Settings header')) { + const nodes = surface.mount(locked) + assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: the sentence stays once`) + assert.equal(reloadButtons(nodes).length, 1, `${surface.name}: one Reload entry`) + } +}) + +test('one shared piece: the sentence is the host sentence, and the control sits outside the live region', () => { + for (const surface of SURFACES) { + const nodes = surface.mount(surface.name.includes('group') ? lockedRoom : locked) + const [sentence] = sentenceNodes(nodes) + const [button] = reloadButtons(nodes) + assert.equal(sentence.props.role, 'status', `${surface.name}: the sentence keeps the status role`) + assert.equal(text(sentence), SENTENCE, `${surface.name}: the words are the host's own, byte for byte`) + assert.equal(draw(sentence).includes(button), false, `${surface.name}: the live region stays text-only`) + assert.ok(draw(nodes).indexOf(sentence) < draw(nodes).indexOf(button), `${surface.name}: the sentence is read first`) + } +}) + +test('a press runs the client\'s own team read, once, and sends no write', async () => { + for (const surface of SURFACES) { + let reads = 0 + const writes = [] + const acts = actions({ + reloadTeam: () => { reads += 1; return Promise.resolve() }, + updateBot: (...args) => { writes.push(['updateBot', ...args]); return Promise.resolve() }, + updateRoom: (...args) => { writes.push(['updateRoom', ...args]); return Promise.resolve() }, + setPrefs: (...args) => { writes.push(['setPrefs', ...args]); return Promise.resolve() }, + }) + const nodes = mountFor(surface, surface.name.includes('group') ? lockedRoom : locked, acts) + const [button] = reloadButtons(nodes) + assert.ok(button, `${surface.name}: the entry is drawn`) + button.props.onClick() + await Promise.resolve() + await Promise.resolve() + assert.equal(reads, 1, `${surface.name}: one press reads the team once`) + assert.deepEqual(writes, [], `${surface.name}: the press sends no write`) + } +}) + +const mountFor = (surface, roster, acts) => { + if (surface.name === 'Settings header') return mountSettings(roster, 'general', null, acts) + if (surface.name === 'Inbox dialog') return mountInbox(roster, acts) + if (surface.name === 'Memory panel') return mountMemory(roster, acts) + return mountDrawer(roster, surface.name.includes('settings') ? 'settings' : 'details', surface.name.includes('group') ? 'r1' : 'chief', acts) +} + +test('a refused reload is reported through the client\'s error channel, not left unhandled', async () => { + const failure = new Error('network error: Failed to fetch') + const seen = [] + const nodes = mountSettings(locked, 'general', null, actions({ reloadTeam: () => Promise.reject(failure), reportError: (error, where) => seen.push([error, where]) })) + const [button] = reloadButtons(nodes) + button.props.onClick() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + assert.deepEqual(seen, [[failure, 'reload']]) +}) + +test('a writable window, or a host sentence of only whitespace, draws neither sentence nor entry', () => { + for (const surface of SURFACES) { + for (const readOnly of [undefined, '', ' ', '\n\t ']) { + const roster = { ...(surface.name.includes('group') ? baseRoom : base), ...(readOnly === undefined ? {} : { readOnly }) } + const nodes = mountFor(surface, roster, actions()) + assert.equal(sentenceNodes(nodes).length, 0, `${surface.name}, ${JSON.stringify(readOnly)}: no sentence`) + assert.equal(reloadButtons(nodes).length, 0, `${surface.name}, ${JSON.stringify(readOnly)}: no entry`) + } + } +})