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`) + } + } +})