Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2035-schedules-unknown-guard.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
3 changes: 2 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)(
Expand Down
7 changes: 6 additions & 1 deletion core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <reason>"; 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: <time>`, `Paused` or `Finished` — and an unreadable stored instant falls back to the repeat rule instead of throwing, so one corrupt `scheduledAt` cannot take the panel down. That empty state's button is the only one that opens the new-task form (`openSettings('schedules', bot.id)`); a row opens the settings list (#2024). The line draws three rows, so above three the title carries the real count (`Scheduled tasks · 5`) and one final row (`2 more not shown`) opens that same settings list — never the new-task form, which only the empty state means (#2030). Tests: `core/test/schedules-client.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: <reason>"; 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: <time>`, `Paused` or `Finished` — and an unreadable stored instant falls back to the repeat rule instead of throwing, so one corrupt `scheduledAt` cannot take the panel down. That empty state's button is the only one that opens the new-task form (`openSettings('schedules', bot.id)`); a row opens the settings list (#2024). The line draws three rows, so above three the title carries the real count (`Scheduled tasks · 5`) and one final row (`2 more not shown`) opens that same settings list — never the new-task form, which only the empty state means (#2030). Until a read answers, the shared view is `null` and the line draws nothing rather than claiming the Bot has no tasks: the empty state belongs to a read that answers zero, and a first read that fails leaves the view unread, so the line stays absent instead of offering the form (#2035). Tests: `core/test/schedules-client.test.js`. |
| `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. Since #1706 a failed workflow run is one *Needs you* row, on the same list and `InboxCard` path: the newest failure per graph inside that same 7-day window (`workflow/state` `runs` + `history`, deduped by run id), titled `<graph> · <node>` when the failure names a node and carrying `failure.text || failure.code` as its reason and `data-run` (never `data-task`); it has no `chat`/`place`, **Open** goes to Settings → Workflow graphs (`actions.openSettings('workflow')`, no per-graph deep link yet), and `inboxAnswer` returns `undefined` for it, so it has no answer control. The sidebar badge and the dialog read the same pack through `useWorkflows` (non-ready phase → no rows and no error line). Tests: `core/test/inbox-workflow-failure.test.js`. |
| `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. Since #1347 the error goes through `failureSentence` (`core/src/shared/failure-copy.js`), which turns a provider refusal stored as a cut-off JSON body into a sentence that names the feature and where to change the model; the Inbox reads the same helper. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`, `core/test/ui-clickthrough.test.js`. |
| `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `<untrusted-message>` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. |
Expand Down
Loading
Loading