diff --git a/changelog.d/2035-schedules-unknown-guard.fixed.md b/changelog.d/2035-schedules-unknown-guard.fixed.md new file mode 100644 index 000000000..6b0118c84 --- /dev/null +++ b/changelog.d/2035-schedules-unknown-guard.fixed.md @@ -0,0 +1 @@ +A Bot's details panel no longer says "No scheduled tasks yet. Add one" before it has read the Bot's scheduled tasks: the line kept the shared view's `null` (nothing read yet) and treated it as an empty list, so the first frame — and every frame after a first read that failed, since the error was swallowed and the shared view stayed `null` — offered the new-task form on a Bot that may well have tasks. It now draws nothing until a read answers, the way the settings card beside it already did, and the empty state is kept for a read that answers zero ([#2035](https://github.com/ThinkFlowLab/FlyBot/issues/2035)). diff --git a/core/client.js b/core/client.js index 8d4253ad3..4a7655cce 100644 --- a/core/client.js +++ b/core/client.js @@ -13510,7 +13510,8 @@ function BotSchedules({ bot, roster, actions }) { } function ScheduleSummary({ bot, actions }) { const view = useSchedules(actions); - const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + if (view === null) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; const shown = tasks.slice(0, 3); const hidden = tasks.length - shown.length; return (0, import_react25.createElement)( diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index f1d3b2893..ef3e839d1 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -423,7 +423,12 @@ export function BotSchedules({ bot, roster, actions }) { // form instead — the form is what the empty state's button means (#2030). export function ScheduleSummary({ bot, actions }) { const view = useSchedules(actions) - const tasks = view?.available ? view.tasks.filter(task => task.botId === bot.id) : [] + // #2035: the shared view starts null and the first read lands a frame later, so "no view yet" is + // not "no tasks". The settings card above draws nothing for it; this line did the opposite and + // offered the new-task form. `view.available === false` (Plugins off) is a different state and + // keeps its own line below. + if (view === null) return null + const tasks = view.available ? view.tasks.filter(task => task.botId === bot.id) : [] const shown = tasks.slice(0, 3) const hidden = tasks.length - shown.length return h('div', null, diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 572abb502..a9eb147bf 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -481,3 +481,45 @@ test('#2030: three tasks keep the bare title and no more row; no tasks keep the add.props.onClick() assert.deepEqual(actions.calls, [['schedules', 'b1']], 'the empty state still opens the new-task form') }) + +// #2035: the shared view starts as `null` and the first read lands a frame later, so the first +// frame of a Bot's details panel has no view at all. The summary took that for "this Bot has no +// tasks": it drew the empty state and a button opening the *new-task form*, while the settings +// card beside it drew nothing for the same state. A first read that fails keeps `shared` null, so +// this was not one flickering frame. +test('#2035: a schedules view that has not been read yet draws no empty state and opens nothing', () => { + const actions = opened() + seeded.push(null) + const card = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + assert.deepEqual(text(card), [], 'an unknown view draws no line, in particular not "No scheduled tasks yet"') + assert.equal(buttonsOf(card).length, 0, 'and no button to open the new-task form') + assert.deepEqual(actions.calls, [], 'nothing is opened from a view nobody read') +}) + +test('#2035: a read that answers zero tasks keeps the empty state and its add button', () => { + const actions = opened() + seeded.push({ available: true, tasks: [] }) + const card = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + assert.deepEqual(text(sectionTitle(card)[0]), ['Scheduled tasks']) + assert.deepEqual(text(card).filter(line => line !== 'Scheduled tasks'), ['No scheduled tasks yet. Add one']) + const add = buttonsOf(card) + assert.equal(add.length, 1) + assert.equal(add[0].props.className, 'bt-more') + add[0].props.onClick() + assert.deepEqual(actions.calls, [['schedules', 'b1']], 'a read zero keeps the new-task form') +}) + +test('#2035: five tasks keep the #2030 shape — count in the title, three rows, one more row', () => { + const actions = opened() + seeded.push({ available: true, tasks: fiveTasks() }) + const card = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + assert.deepEqual(text(sectionTitle(card)[0]), ['Scheduled tasks · 5'], 'the count still comes from the read') + const rows = buttonsOf(card).filter(button => button.props.className === 'bt-task-line') + assert.equal(rows.length, 3, 'three rows') + const more = buttonsOf(card).filter(button => button.props.className === 'bt-more') + assert.equal(more.length, 1, 'one more row') + assert.deepEqual(text(more[0]), ['2 more not shown']) + rows[0].props.onClick() + more[0].props.onClick() + assert.deepEqual(actions.calls, [['schedules'], ['schedules']], 'rows and the more row open the list, never the form') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 355e004aa..a7b75ce1c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -39,7 +39,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: