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: