From 88f45d7784f37c97a1849ce0b5da027fd1dff967 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:45:34 +0800 Subject: [PATCH] schedules: a read-only window offers no schedule write, and a refused write says so (#2232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page never read the window lock, so Settings → Scheduled tasks drew every write the host refuses in a read-only window, and the refusals it did hit were dropped: a rejected Pause, Resume or Delete only cleared the busy flag, and two visible lines carried no role. The lock is now read once, the way the rest of the client reads it, and the write controls are not rendered rather than disabled. A row keeps one refusal slot: a rejected Pause, Resume, Delete or Retry puts the host's own sentence there as a role=alert, cleared on the next attempt and on success; the Quiet hours and create-form error lines gain the same role. The ds-bot patches and the client bundle are regenerated from these sources (generate rewrote the client-bundle patch and the whole-file test mirror). --- changelog.d/2232-schedules-read-only.fixed.md | 1 + core/client.js | 70 ++- core/src/client/schedules.js | 73 ++- core/test/schedules-client.test.js | 140 +++++- docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 112 +++-- patches/ds-bot/0008-client-bundle.patch | 462 +++++++++--------- .../files/test/schedules-client.test.js | 140 +++++- 8 files changed, 688 insertions(+), 312 deletions(-) create mode 100644 changelog.d/2232-schedules-read-only.fixed.md diff --git a/changelog.d/2232-schedules-read-only.fixed.md b/changelog.d/2232-schedules-read-only.fixed.md new file mode 100644 index 000000000..acfd8c6c2 --- /dev/null +++ b/changelog.d/2232-schedules-read-only.fixed.md @@ -0,0 +1 @@ +A read-only FlyBot window no longer draws the scheduled-task writes the host would refuse — the Quiet hours toggle and window fields, New task, Edit, Copy, Pause/Resume, Delete and Retry — and a refused Pause, Resume, Delete or Retry now says the host's own sentence on that row instead of nothing, announced to a screen reader ([#2232](https://github.com/ThinkFlowLab/FlyBot/issues/2232)). diff --git a/core/client.js b/core/client.js index d0c77cac6..d684de9d1 100644 --- a/core/client.js +++ b/core/client.js @@ -13247,9 +13247,12 @@ function lastRunText(run) { return t("Last run {time}", { time }); } } -function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { +var lockedWindow = (roster) => typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { const [confirming, setConfirming] = (0, import_react25.useState)(false); const [busy, setBusy] = (0, import_react25.useState)(false); + const [rowError, setRowError] = (0, import_react25.useState)(""); + const refuse = (error) => setRowError(hostText(error?.message ?? String(error))); (0, import_react25.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); @@ -13261,26 +13264,38 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { return; } setBusy(true); - void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); + setRowError(""); + void actions.scheduleDelete(task.sessionId, task.id).then((next) => { + setBusy(false); + setRowError(""); + onView(next); + }, (error) => { + setBusy(false); + refuse(error); + }); }; const toggle = () => { setBusy(true); + setRowError(""); const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause; void step(task.sessionId, task.id).then((next) => { setBusy(false); + setRowError(""); onView(next); - }, () => setBusy(false)); + }, (error) => { + setBusy(false); + refuse(error); + }); }; - const [retryError, setRetryError] = (0, import_react25.useState)(""); const retry = () => { setBusy(true); - setRetryError(""); + setRowError(""); void actions.scheduleRetry(task.sessionId, task.id).then((next) => { setBusy(false); onView(next); }, (error) => { setBusy(false); - setRetryError(hostText(error?.message ?? String(error))); + refuse(error); }); }; const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0; @@ -13298,14 +13313,16 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { (0, import_react25.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), task.prompt && task.prompt !== task.title ? (0, import_react25.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, last ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, - retryError ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null + rowError ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, rowError) : null ), (0, import_react25.createElement)("span", { className: "bt-task-next" }, nextText(task)), - failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, - onEdit ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react25.createElement)(PencilIcon)) : null, - onCopy ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react25.createElement)(DuplicateIcon)) : null, - stoppable ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, - (0, import_react25.createElement)("button", { + // A locked window draws none of the writes the host would refuse (#2232). Hiding, not + // disabling: a disabled button still says this window can do it. + !locked && failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, + !locked && onEdit ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react25.createElement)(PencilIcon)) : null, + !locked && onCopy ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react25.createElement)(DuplicateIcon)) : null, + !locked && stoppable ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + locked ? null : (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, @@ -13441,7 +13458,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) { ) : null ) ), - error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, + error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, (0, import_react25.createElement)( "div", { className: "bt-set-row bt-task-foot" }, @@ -13468,7 +13485,7 @@ function AutomationOff({ actions }) { (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ); } -function QuietHoursCard({ view, actions }) { +function QuietHoursCard({ view, actions, locked }) { const quiet = view.quietHours ?? { on: false }; const [busy, setBusy] = (0, import_react25.useState)(false); const [error, setError] = (0, import_react25.useState)(""); @@ -13507,7 +13524,7 @@ function QuietHoursCard({ view, actions }) { (0, import_react25.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) ), - (0, import_react25.createElement)("button", { + locked ? null : (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, @@ -13515,7 +13532,7 @@ function QuietHoursCard({ view, actions }) { onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) }, quiet.on ? t("Turn off") : t("Turn on")) ), - quiet.on ? (0, import_react25.createElement)( + quiet.on && !locked ? (0, import_react25.createElement)( "div", { className: "bt-set-row bt-bs-row" }, (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Window"))), @@ -13528,7 +13545,7 @@ function QuietHoursCard({ view, actions }) { (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) ) ) : null, - error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, + error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error)) : null, quiet.on && unchecked ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null ) ); @@ -13537,6 +13554,7 @@ function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions); const [adding, setAdding] = (0, import_react25.useState)(Boolean(start)); const [editing, setEditing] = (0, import_react25.useState)(null); + const locked = lockedWindow(roster); const refused = schedulesReadFailure(view?.readFailure); if (refused !== null) return (0, import_react25.createElement)("div", { className: "bt-set-hint bt-danger", role: "status" }, refused); if (view === null) return (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); @@ -13555,8 +13573,10 @@ function SchedulesPage({ roster, actions, start }) { ...roster.rooms.map((room2) => [room2.id, (0, import_react25.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ].filter(([, , , tasks]) => tasks.length > 0); return [ - (0, import_react25.createElement)(QuietHoursCard, { key: "quiet", view, actions }), - adding ? (0, import_react25.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react25.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + (0, import_react25.createElement)(QuietHoursCard, { key: "quiet", view, actions, locked }), + // A locked window draws no create control and opens no form (#2232): the header already says + // why, and a button the host refuses is the defect this page had. + locked ? null : adding ? (0, import_react25.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react25.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { setEditing(null); setAdding(true); } }, (0, import_react25.createElement)(PlusIcon), t("New task"))), @@ -13564,15 +13584,15 @@ function SchedulesPage({ roster, actions, start }) { "section", { key: id, className: "bt-set-section" }, (0, import_react25.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react25.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) + (0, import_react25.createElement)(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; } -function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { - const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; +function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { + const open = !locked && editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; const [copying, setCopying] = (0, import_react25.useState)(null); const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; - const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react25.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); + const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react25.createElement)(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); return (0, import_react25.createElement)( import_react25.Fragment, null, @@ -13620,6 +13640,7 @@ function CopyRow({ task, roster, actions, onView, onCancel }) { function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); const [editing, setEditing] = (0, import_react25.useState)(null); + const locked = lockedWindow(roster); const refused = schedulesReadFailure(view?.readFailure); if (refused !== null) { return (0, import_react25.createElement)( @@ -13653,7 +13674,8 @@ function BotSchedules({ bot, roster, actions }) { editing, roster, actions, - onEdit: setEditing, + locked, + onEdit: locked ? void 0 : setEditing, onDone: (next) => { publish(next); setEditing(null); diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 4c2471f17..56b90f718 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -117,9 +117,17 @@ export function lastRunText(run) { } } -function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { +// The window lock, read the way the rest of the client reads it (#2232): the Settings header draws +// the sentence, and this page draws no write the host would refuse. +const lockedWindow = roster => typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' + +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster, locked }) { const [confirming, setConfirming] = useState(false) const [busy, setBusy] = useState(false) + // One refusal slot per row (#2232): a rejected Pause, Resume, Delete or Retry says the host's own + // sentence here, in the alert the row already had for Retry. + const [rowError, setRowError] = useState('') + const refuse = error => setRowError(hostText(error?.message ?? String(error))) useEffect(() => { if (!confirming) return undefined const timer = setTimeout(() => setConfirming(false), 3000) @@ -128,18 +136,22 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { const remove = () => { if (!confirming) { setConfirming(true); return } setBusy(true) - void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)) + setRowError('') + void actions.scheduleDelete(task.sessionId, task.id) + .then((next) => { setBusy(false); setRowError(''); onView(next) }, (error) => { setBusy(false); refuse(error) }) } const toggle = () => { setBusy(true) + setRowError('') const step = task.status === 'paused' ? actions.scheduleResume : actions.schedulePause - void step(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, () => setBusy(false)) + void step(task.sessionId, task.id) + .then((next) => { setBusy(false); setRowError(''); onView(next) }, (error) => { setBusy(false); refuse(error) }) } - const [retryError, setRetryError] = useState('') const retry = () => { setBusy(true) - setRetryError('') - void actions.scheduleRetry(task.sessionId, task.id).then((next) => { setBusy(false); onView(next) }, (error) => { setBusy(false); setRetryError(hostText(error?.message ?? String(error))) }) + setRowError('') + void actions.scheduleRetry(task.sessionId, task.id) + .then((next) => { setBusy(false); onView(next) }, (error) => { setBusy(false); refuse(error) }) } const room = task.roomId ? roster.roomsById[task.roomId] : undefined const stoppable = task.status === 'active' || task.status === 'paused' @@ -152,13 +164,15 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { h('span', { className: 'bt-set-hint' }, [timingText(task), room ? t('in {name}', { name: room.name }) : ''].filter(Boolean).join(' · ')), task.prompt && task.prompt !== task.title ? h('span', { className: 'bt-task-prompt' }, task.prompt) : null, last ? h('span', { className: 'bt-task-last', 'data-failed': failed || undefined }, last) : null, - retryError ? h('span', { className: 'bt-task-last', 'data-failed': '', role: 'alert' }, retryError) : null), + rowError ? h('span', { className: 'bt-task-last', 'data-failed': '', role: 'alert' }, rowError) : null), h('span', { className: 'bt-task-next' }, nextText(task)), - failed && task.status !== 'paused' && actions.scheduleRetry ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, title: t('Run the task once now'), onClick: retry }, t('Retry')) : null, - onEdit ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Edit'), title: t('Edit'), onClick: () => onEdit(task) }, h(PencilIcon)) : null, - onCopy ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Copy to another Bot or group chat'), title: t('Copy to…'), onClick: () => onCopy(task) }, h(DuplicateIcon)) : null, - stoppable ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, onClick: toggle }, task.status === 'paused' ? t('Resume') : t('Pause')) : null, - h('button', { + // A locked window draws none of the writes the host would refuse (#2232). Hiding, not + // disabling: a disabled button still says this window can do it. + !locked && failed && task.status !== 'paused' && actions.scheduleRetry ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, title: t('Run the task once now'), onClick: retry }, t('Retry')) : null, + !locked && onEdit ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Edit'), title: t('Edit'), onClick: () => onEdit(task) }, h(PencilIcon)) : null, + !locked && onCopy ? h('button', { type: 'button', className: 'bt-mini bt-task-tool', disabled: busy, 'aria-label': t('Copy to another Bot or group chat'), title: t('Copy to…'), onClick: () => onCopy(task) }, h(DuplicateIcon)) : null, + !locked && stoppable ? h('button', { type: 'button', className: 'bt-mini bt-task-pause', disabled: busy, onClick: toggle }, task.status === 'paused' ? t('Resume') : t('Pause')) : null, + locked ? null : h('button', { type: 'button', className: 'bt-mini bt-task-remove', 'data-confirm': confirming || undefined, disabled: busy, 'aria-label': confirming ? t('Click again to delete') : t('Delete task'), title: confirming ? undefined : t('Delete task'), onClick: remove, }, confirming ? t('Delete') : h(TrashIcon))) @@ -265,7 +279,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) { h('input', { className: 'bt-input bt-task-count', type: 'number', min: 1, value: count, 'aria-label': t('How many'), onChange: event => setCount(event.target.value) }), h('select', { className: 'bt-select bt-task-unit', value: unit, 'aria-label': t('Unit'), onChange: event => setUnit(event.target.value) }, UNITS.map(([id, , label]) => h('option', { key: id, value: id }, t(label))))) : null)), - error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint' }, error)) : null, + error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint', role: 'alert' }, error)) : null, h('div', { className: 'bt-set-row bt-task-foot' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: onCancel }, t('Cancel')), h('button', { type: 'submit', className: 'bt-send bt-soft-sm', disabled: busy || !target || title.trim() === '' || prompt.trim() === '' }, @@ -291,7 +305,7 @@ function AutomationOff({ actions }) { // Quiet hours (#1166): a window checked when a task is created or edited, never a mute — // nothing here runs at delivery time. A repeat or cron task can still fire inside it, and // the card says so instead of claiming a silence FlyBot cannot keep. -function QuietHoursCard({ view, actions }) { +function QuietHoursCard({ view, actions, locked }) { const quiet = view.quietHours ?? { on: false } const [busy, setBusy] = useState(false) const [error, setError] = useState('') @@ -323,18 +337,18 @@ function QuietHoursCard({ view, actions }) { h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, quiet.on ? t('On, {from}–{to}', { from: quiet.from, to: quiet.to }) : t('Off')), h('span', { className: 'bt-set-hint' }, t('A new or edited task cannot be due inside the window. It never mutes a task that already exists.'))), - h('button', { + locked ? null : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, 'aria-pressed': quiet.on, onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }), }, quiet.on ? t('Turn off') : t('Turn on'))), - quiet.on ? h('div', { className: 'bt-set-row bt-bs-row' }, + quiet.on && !locked ? h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Window'))), h('div', { className: 'bt-task-when' }, h('input', { className: 'bt-input bt-task-time', type: 'time', value: from, 'aria-label': t('Quiet hours start'), disabled: busy, onChange: event => setFrom(event.target.value) }), h('span', { className: 'bt-task-every' }, '–'), h('input', { className: 'bt-input bt-task-time', type: 'time', value: to, 'aria-label': t('Quiet hours end'), disabled: busy, onChange: event => setTo(event.target.value) }), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => save({ on: true, from, to }) }, t('Save window')))) : null, - error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint' }, error)) : null, + error ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-danger bt-set-hint', role: 'alert' }, error)) : null, quiet.on && unchecked ? h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, t('A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep.'))) : null)) @@ -344,6 +358,7 @@ export function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions) const [adding, setAdding] = useState(Boolean(start)) const [editing, setEditing] = useState(null) + const locked = lockedWindow(roster) const refused = schedulesReadFailure(view?.readFailure) if (refused !== null) return h('div', { className: 'bt-set-hint bt-danger', role: 'status' }, refused) if (view === null) return h('div', { className: 'bt-set-hint' }, t('Loading…')) @@ -355,27 +370,30 @@ export function SchedulesPage({ roster, actions, start }) { ...roster.rooms.map(room => [room.id, h(RoomAvatar, { room, roster, size: 18 }), room.name, view.tasks.filter(task => task.botId === undefined && task.roomId === room.id)]), ].filter(([, , , tasks]) => tasks.length > 0) return [ - h(QuietHoursCard, { key: 'quiet', view, actions }), - adding - ? h(TaskForm, { key: 'form', roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) - : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => { setEditing(null); setAdding(true) } }, h(PlusIcon), t('New task'))), + h(QuietHoursCard, { key: 'quiet', view, actions, locked }), + // A locked window draws no create control and opens no form (#2232): the header already says + // why, and a button the host refuses is the defect this page had. + locked ? null + : adding + ? h(TaskForm, { key: 'form', roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) + : h('div', { key: 'add', className: 'bt-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-with-icon', onClick: () => { setEditing(null); setAdding(true) } }, h(PlusIcon), t('New task'))), byChat.length === 0 && !adding // #2112: the list is narrowed to the caller's rows, so an empty page can mean "another // member scheduled something". `someHidden` is the host saying it dropped such a row. ? h('div', { key: 'none', className: 'bt-set-card' }, h('div', { className: 'bt-set-row' }, h('span', { className: 'bt-set-hint' }, view.someHidden ? t('No scheduled tasks you can see. Another member\'s tasks stay hidden.') : t('No scheduled tasks yet.')))) : byChat.map(([id, avatar, name, tasks]) => h('section', { key: id, className: 'bt-set-section' }, h('h3', { className: 'bt-task-head' }, avatar, name), - h(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }))), + h(TaskList, { tasks, editing, roster, actions, locked, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }))), ] } // One chat's rows; the task being edited opens the form in its place, and so does the one // being copied, which opens only the target picker. -function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { - const open = editing ? tasks.find(task => task.id === editing.id && task.sessionId === editing.sessionId) : undefined +function TaskList({ tasks, editing, roster, actions, locked, onEdit, onDone, onCancel, extra = [] }) { + const open = !locked && editing ? tasks.find(task => task.id === editing.id && task.sessionId === editing.sessionId) : undefined const [copying, setCopying] = useState(null) const source = copying ? tasks.find(task => task.id === copying.id && task.sessionId === copying.sessionId) : undefined - const rows = tasks.filter(task => task !== open && task !== source).map(task => h(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : undefined })) + const rows = tasks.filter(task => task !== open && task !== source).map(task => h(TaskRow, { key: task.id, task, actions, roster, locked, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : undefined })) return h(Fragment, null, open ? h(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, source ? h(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { setCopying(null); onDone(next) }, onCancel: () => setCopying(null) }) : null, @@ -416,6 +434,9 @@ export function CopyRow({ task, roster, actions, onView, onCancel }) { export function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions) const [editing, setEditing] = useState(null) + // A locked window keeps the list and its New task row: that row only opens the settings list, + // which draws no create control once this page respects the lock (#2232). + const locked = lockedWindow(roster) const refused = schedulesReadFailure(view?.readFailure) if (refused !== null) { return h('section', { className: 'bt-set-section' }, @@ -433,7 +454,7 @@ export function BotSchedules({ bot, roster, actions }) { h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-set-hint' }, t('Switch on Automation tasks on the Plugins page.'))), h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openPlugins(actions) }, t('Open Plugins')))) : h(TaskList, { - tasks, editing, roster, actions, onEdit: setEditing, + tasks, editing, roster, actions, locked, onEdit: locked ? undefined : setEditing, onDone: (next) => { publish(next); setEditing(null) }, onCancel: () => setEditing(null), extra: [h('button', { key: 'new', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => actions.openSettings('schedules', bot.id) }, h(PlusIcon), t('New task'))], diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 05e809404..37df86003 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -21,7 +21,10 @@ const h = (type, props, ...children) => ({ type, props: { ...props, children } } const OFF = { available: false, tasks: [] } // States handed out in order before the defaults, to render a page with tasks or a row being edited. const seeded = [] -const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [seeded.length > 0 ? seeded.shift() : v === null ? OFF : v, () => {}], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } +// The values handed to a state setter: this stub never re-renders, so a test reads what a refusal +// tried to say instead of waiting for a frame (#2232). Tests that read it clear it first. +const sets = [] +const react = { createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useState: v => [seeded.length > 0 ? seeded.shift() : v === null ? OFF : v, next => { sets.push(next) }], useMemo: f => f(), useRef: current => ({ current }), useCallback: f => f, useSyncExternalStore: (_s, get) => get() } const module = { exports: {} } runInNewContext(outputFiles[0].text, { module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, @@ -643,3 +646,138 @@ test('#2112: the Bot details line says the list is narrowed, not that none exist assert.ok(!lines.includes('No scheduled tasks yet. Add one'), lines.join('|')) assert.ok(lines.some(line => /you can see/.test(line) && /Add one/.test(line)), lines.join('|')) }) + +// #2232 (P-451): a read-only window drew the whole scheduled-task write surface — the Quiet hours +// toggle and window inputs, New task, Edit, Copy, Pause/Resume and Delete — and the host refuses +// every one of them, while a refused Pause/Resume/Delete said nothing at all and two visible +// refusal lines carried no role. The lock is read the way the rest of the client reads it. +const LOCK = 'Another FlyBot window has this home open; this window is read-only.' +const LOCKED_ROSTER = { ...ROSTER, readOnly: LOCK } +const WRITE_LABELS = ['Turn on', 'Turn off', 'Save window', 'New task', 'Edit', 'Copy to another Bot or group chat', 'Pause', 'Resume', 'Delete task', 'Retry'] +const labelOf = node => text(node).join(' ').trim() || String(node.props?.['aria-label'] ?? '') +const writeControls = tree => all(tree).filter(node => node.type === 'button' && WRITE_LABELS.includes(labelOf(node))) +const fieldControls = tree => all(tree).filter(node => ['form', 'input', 'select', 'textarea'].includes(node.type)) +const alertsOf = tree => all(tree).filter(node => node.props?.role === 'alert') +// Typed actions: every write the page can wire, so a missing one cannot hide a control. +const scheduleActions = (extra = {}) => ({ + ...spy(), + schedules: async () => ({ available: true, tasks: [ACTIVE] }), + scheduleCreate: async () => OFF, scheduleUpdate: async () => OFF, + schedulePause: async () => OFF, scheduleResume: async () => OFF, + scheduleDelete: async () => OFF, scheduleRetry: async () => OFF, + setPrefs: async () => ({}), + ...extra, +}) +const tick = () => new Promise(resolve => setImmediate(resolve)) + +test('#2232: a read-only window draws no scheduled-task write control', () => { + const view = { available: true, tasks: [ACTIVE], quietHours: QUIET } + seeded.push(view) + const locked = render(SchedulesPage({ roster: LOCKED_ROSTER, actions: scheduleActions(), start: null })) + const labels = writeControls(locked).map(labelOf) + assert.deepEqual(labels, [], `a read-only window offers no scheduled-task write, saw ${labels.join(',')}`) + assert.deepEqual(fieldControls(locked).map(node => node.type), [], 'and no write form, input or select') + // The read half stays: the task is listed and the quiet-hours state is still stated. + assert.ok(text(locked).includes('Briefing'), 'the task is still listed') + assert.ok(text(locked).includes('On, 22:00–07:00'), 'the quiet-hours state is still shown') + // The Settings header owns the one read-only sentence; this page draws no second copy (#2199). + assert.ok(!text(locked).some(line => line === LOCK), 'the page draws no second read-only sentence') + // The control: the same page in a writable window draws the whole surface, so the empty list is + // the lock and not a page that failed to render. + seeded.push(view) + const writable = render(SchedulesPage({ roster: ROSTER, actions: scheduleActions(), start: null })) + const drawn = writeControls(writable).map(labelOf).sort() + assert.deepEqual(drawn, ['Copy to another Bot or group chat', 'Delete task', 'Edit', 'New task', 'Pause', 'Save window', 'Turn off'], `a writable window keeps every write, saw ${drawn.join(',')}`) + assert.equal(fieldControls(writable).filter(node => node.type === 'input' && node.props.type === 'time').length, 2, 'the quiet-hours window keeps its two inputs') +}) + +test('#2232: a refused Pause, Resume or Delete says the host sentence in that row, once', async () => { + const sentence = 'bot/read-only: this window is read-only' + const cases = [ + ['Pause', ACTIVE, false, 'schedulePause', button => text(button).includes('Pause')], + ['Resume', PAUSED, false, 'scheduleResume', button => text(button).includes('Resume')], + ['Delete', ACTIVE, true, 'scheduleDelete', button => button.props?.['aria-label'] === 'Click again to delete'], + ] + for (const [name, task, confirming, action, pick] of cases) { + sets.length = 0 + const actions = { ...spy(), [action]: async () => { throw new Error(sentence) } } + // BotSchedules states in order: view, editing, copying, confirming, busy, rowError. + seeded.push({ available: true, tasks: [task] }, null, null, confirming, false, '') + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const button = buttonsOf(card).find(pick) + assert.ok(button, `${name} is drawn`) + button.props.onClick() + await tick() + assert.ok(sets.includes(sentence), `${name}: the host sentence reaches the row, saw ${JSON.stringify(sets)}`) + // The row draws it in the alert slot it already had, and only once. + seeded.push({ available: true, tasks: [task] }, null, null, false, false, sentence) + const shown = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + const alerts = alertsOf(shown) + assert.equal(alerts.length, 1, `${name}: exactly one alert in the row, saw ${alerts.length}`) + assert.ok(text(alerts[0]).join(' ').includes(sentence), `${name}: the alert carries the host's own sentence`) + } +}) + +test('#2232: a successful Pause speaks nothing and the page reads the host view again', async () => { + sets.length = 0 + const next = { available: true, tasks: [{ ...ACTIVE, status: 'paused' }] } + const actions = { ...spy(), schedulePause: async () => next } + seeded.push({ available: true, tasks: [ACTIVE] }, null, null, false, false, '') + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + buttonsOf(card).find(button => text(button).includes('Pause')).props.onClick() + await tick() + assert.ok(!sets.some(value => typeof value === 'string' && value !== ''), `a success says nothing, saw ${JSON.stringify(sets)}`) + // `onView` published the host's answer: the next render reads it, and it carries no alert. + const after = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions })) + assert.ok(text(after).includes('Paused'), 'the row shows the host view after the pause') + assert.equal(alertsOf(after).length, 0, 'no alert after a successful pause') +}) + +test('#2232: a failed row keeps its Retry alert, in the same slot', () => { + const sentence = 'SERVER 503: upstream busy' + const failed = { ...ACTIVE, lastRun: { at: Date.now(), result: 'failed', reason: sentence } } + // view, editing, copying, confirming, busy, rowError + seeded.push({ available: true, tasks: [failed] }, null, null, false, false, sentence) + const card = render(BotSchedules({ bot: { id: 'b1' }, roster: ROSTER, actions: spy() })) + const alerts = alertsOf(card) + assert.equal(alerts.length, 1, 'one alert on a failed row') + assert.ok(text(alerts[0]).join(' ').includes(sentence), 'and it carries the sentence') +}) + +test('#2232: Quiet hours and the form announce a refusal, not only print it', async () => { + const sentence = 'bot/read-only: this window is read-only' + // The Quiet hours save: the sentence reaches the error state, and that line is an alert. + sets.length = 0 + const quietView = { available: true, tasks: [], quietHours: { on: false } } + seeded.push(quietView) + const refused = render(SchedulesPage({ roster: ROSTER, actions: scheduleActions({ setPrefs: async () => { throw new Error(sentence) } }), start: null })) + buttonsOf(refused).find(button => text(button).includes('Turn on')).props.onClick() + await tick() + assert.ok(sets.includes(sentence), `Quiet hours: the sentence reaches the card, saw ${JSON.stringify(sets)}`) + // SchedulesPage states in order: view, adding, editing, quiet busy, quiet error. + seeded.push(quietView, false, null, false, sentence) + const quietShown = render(SchedulesPage({ roster: ROSTER, actions: scheduleActions(), start: null })) + const quietAlerts = alertsOf(quietShown) + assert.equal(quietAlerts.length, 1, 'one alert on the Quiet hours card') + assert.ok(text(quietAlerts[0]).join(' ').includes(sentence)) + + // The create form: the refusal lands in the error state, and that line is an alert too. + sets.length = 0 + const formActions = scheduleActions({ scheduleCreate: async () => { throw new Error(sentence) } }) + seeded.push({ available: true, tasks: [] }) + const page = render(SchedulesPage({ roster: ROSTER, actions: formActions, start: 'b1' })) + const form = all(page).find(node => node.type === 'form') + assert.ok(form, 'the new-task form is open') + form.props.onSubmit({ preventDefault() {} }) + await tick() + assert.ok(sets.includes(sentence), `form: the sentence reaches the form, saw ${JSON.stringify(sets)}`) + const start = formStart(ACTIVE) + seeded.push( + { available: true, tasks: [] }, true, null, false, '', '22:00', '07:00', + 'b1', start.title, start.prompt, start.kind, start.at, start.time, start.weekdays, start.count, start.unit, false, sentence, + ) + const formShown = render(SchedulesPage({ roster: ROSTER, actions: formActions, start: 'b1' })) + const formAlerts = alertsOf(formShown) + assert.equal(formAlerts.length, 1, 'one alert on the form') + assert.ok(text(formAlerts[0]).join(' ').includes(sentence)) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index fb9cc45fd..c70405796 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -40,7 +40,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. | | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. | -| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; a new task opens with no target chosen — **Send to** reads "Pick a Bot or group chat" and **Create task** stays disabled until one is picked, so one click cannot create the task for the first Bot (#1334); an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. A repeat whose interval is not a whole number of minutes is stated as seconds (`Every 90 seconds`, #1651): the edit form opens holding that exact value in the **seconds** unit, and saving a task whose repeat was not touched sends the stored interval back unchanged instead of a rounded number of minutes. The details panel's line (`ScheduleSummary`) lists every task of that Bot, paused and finished ones included, because a Bot whose tasks are all paused has tasks while a Bot with none is the only empty state; each row answers when — `Next: