From 5c34342690a3a9ad948f084c57515e204f138224 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:47:15 +0800 Subject: [PATCH] client: state the scheduled task count and open the list from the row after three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The details panel's Scheduled tasks line drew tasks.slice(0, 3) and said nothing about the rest, so five tasks looked like three and the only way to learn the real number was to open Settings by hand. Above three the title now carries the count (Scheduled tasks · 5) and one row after the three says how many are left and opens the settings list. That row calls openSettings('schedules') with no bot id: with an id the settings page opens the new-task form, which is what only the empty state's button means. Three or fewer tasks and no tasks render as before (#2030). --- .../2030-schedules-summary-more.changed.md | 1 + core/client.js | 24 +- core/src/client/i18n.js | 1 + core/src/client/schedules.js | 13 +- core/test/schedules-client.test.js | 49 + docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 22 +- patches/ds-bot/0008-client-bundle.patch | 7625 ++++++++++------- .../files/test/schedules-client.test.js | 49 + 9 files changed, 4843 insertions(+), 2943 deletions(-) create mode 100644 changelog.d/2030-schedules-summary-more.changed.md diff --git a/changelog.d/2030-schedules-summary-more.changed.md b/changelog.d/2030-schedules-summary-more.changed.md new file mode 100644 index 000000000..a45b111c8 --- /dev/null +++ b/changelog.d/2030-schedules-summary-more.changed.md @@ -0,0 +1 @@ +A Bot's Scheduled tasks line in its details panel says how many tasks the Bot has and, past the three rows it draws, one **2 more not shown** row opens the full list ([#2030](https://github.com/ThinkFlowLab/FlyBot/issues/2030)). diff --git a/core/client.js b/core/client.js index 22833ea05..d85ff4331 100644 --- a/core/client.js +++ b/core/client.js @@ -1282,6 +1282,7 @@ var ZH = { "On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.": "默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。", "No Bots": "没有 Bot", "Scheduled tasks": "定时任务", + "Scheduled tasks · {count}": "定时任务 · {count}", "Every day": "每天", "Every {count} days": "每 {count} 天", "Every hour": "每小时", @@ -13510,17 +13511,24 @@ 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) : []; + const shown = tasks.slice(0, 3); + const hidden = tasks.length - shown.length; return (0, import_react25.createElement)( "div", null, - (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), - tasks.length === 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react25.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react25.createElement)( - "button", - { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react25.createElement)(ClockIcon), - (0, import_react25.createElement)("span", { className: "bt-task-line-title" }, task.title), - (0, import_react25.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) - ))) + (0, import_react25.createElement)("div", { className: "bt-section-title" }, hidden > 0 ? t("Scheduled tasks · {count}", { count: tasks.length }) : t("Scheduled tasks")), + tasks.length === 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react25.createElement)( + "div", + { className: "bt-task-list" }, + shown.map((task) => (0, import_react25.createElement)( + "button", + { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, + (0, import_react25.createElement)(ClockIcon), + (0, import_react25.createElement)("span", { className: "bt-task-line-title" }, task.title), + (0, import_react25.createElement)("span", { className: "bt-task-line-when" }, nextText(task)) + )), + hidden > 0 ? (0, import_react25.createElement)("button", { key: "more", type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules") }, t("{count} more not shown", { count: hidden })) : null + ) ); } var SCHEDULE_CSS = ` diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index d653662b3..d2b071053 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1235,6 +1235,7 @@ const ZH = { 'On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend\'s own Bot never gets it.': '默认对你的每个 Bot 开启,每一轮都可用:它自己的聊天、群聊、定时任务,以及好友使用它时。好友自己的 Bot 永远拿不到。', 'No Bots': '没有 Bot', 'Scheduled tasks': '定时任务', + 'Scheduled tasks · {count}': '定时任务 · {count}', 'Every day': '每天', 'Every {count} days': '每 {count} 天', 'Every hour': '每小时', diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 847a839df..f1d3b2893 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -418,15 +418,22 @@ export function BotSchedules({ bot, roster, actions }) { // The details panel's line: the Bot's next tasks, or how to add one. Paused and finished // tasks are the Bot's tasks too, so only a Bot with none at all gets the empty state (#2024). +// The line draws three rows, so the title carries the real count and one row after them says +// what the cap hid; that row opens the settings list, where a bot id would open the new-task +// 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) : [] + const shown = tasks.slice(0, 3) + const hidden = tasks.length - shown.length return h('div', null, - h('div', { className: 'bt-section-title' }, t('Scheduled tasks')), + h('div', { className: 'bt-section-title' }, hidden > 0 ? t('Scheduled tasks · {count}', { count: tasks.length }) : t('Scheduled tasks')), tasks.length === 0 ? h('button', { type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules', bot.id) }, view?.available === false ? t('Switch on Automation tasks on the Plugins page.') : t('No scheduled tasks yet. Add one')) - : h('div', { className: 'bt-task-list' }, tasks.slice(0, 3).map(task => h('button', { key: task.id, type: 'button', className: 'bt-task-line', onClick: () => actions.openSettings('schedules') }, - h(ClockIcon), h('span', { className: 'bt-task-line-title' }, task.title), h('span', { className: 'bt-task-line-when' }, nextText(task)))))) + : h('div', { className: 'bt-task-list' }, + shown.map(task => h('button', { key: task.id, type: 'button', className: 'bt-task-line', onClick: () => actions.openSettings('schedules') }, + h(ClockIcon), h('span', { className: 'bt-task-line-title' }, task.title), h('span', { className: 'bt-task-line-when' }, nextText(task)))), + hidden > 0 ? h('button', { key: 'more', type: 'button', className: 'bt-more', onClick: () => actions.openSettings('schedules') }, t('{count} more not shown', { count: hidden })) : null)) } export const SCHEDULE_CSS = ` diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 6621e5bbe..572abb502 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -432,3 +432,52 @@ test('#2024: a Bot with no tasks at all keeps the empty state and its add button button.props.onClick() assert.deepEqual(actions.calls, [['schedules', 'b1']], 'only the empty state opens the new-task form') }) + +// #2030: the details panel drew three rows and said nothing about the rest, so five scheduled +// tasks looked like three. The title carries the real count, and one row after the three says +// how many are left and opens the full list — not the new-task form. +const fiveTasks = () => [ + ACTIVE, + PAUSED, + { ...PAUSED, id: 's3', title: 'Third' }, + { ...PAUSED, id: 's4', title: 'Fourth' }, + { ...PAUSED, id: 's5', title: 'Fifth' }, +] +const sectionTitle = (tree) => all(tree).filter(node => node.props?.className === 'bt-section-title') + +test('#2030: five tasks draw three rows and the title says the real count', () => { + const actions = opened() + seeded.push({ available: true, tasks: fiveTasks() }) + const card = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + const rows = buttonsOf(card).filter(button => button.props.className === 'bt-task-line') + assert.equal(rows.length, 3, 'the cap still draws three rows') + const titles = sectionTitle(card) + assert.equal(titles.length, 1) + assert.deepEqual(text(titles[0]), ['Scheduled tasks · 5'], 'the title carries the count, not just "Scheduled tasks"') +}) + +test('#2030: the row after the three says how many are left and opens the list, not the new-task form', () => { + const actions = opened() + seeded.push({ available: true, tasks: fiveTasks() }) + const card = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + const more = buttonsOf(card).filter(button => button.props.className === 'bt-more') + assert.equal(more.length, 1, 'one row after the three') + assert.deepEqual(text(more[0]), ['2 more not shown']) + more[0].props.onClick() + assert.deepEqual(actions.calls, [['schedules']], 'the list, not the new-task form: no Bot id') +}) + +test('#2030: three tasks keep the bare title and no more row; no tasks keep the empty state', () => { + const actions = opened() + seeded.push({ available: true, tasks: fiveTasks().slice(0, 3) }) + const three = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + assert.deepEqual(text(sectionTitle(three)[0]), ['Scheduled tasks'], 'no count suffix for three') + assert.equal(buttonsOf(three).filter(button => button.props.className === 'bt-more').length, 0, 'no more row') + seeded.push({ available: true, tasks: [] }) + const none = render(ScheduleSummary({ bot: { id: 'b1' }, actions })) + assert.deepEqual(text(none).filter(line => line !== 'Scheduled tasks'), ['No scheduled tasks yet. Add one']) + const add = buttonsOf(none)[0] + assert.equal(add.props.className, 'bt-more') + add.props.onClick() + assert.deepEqual(actions.calls, [['schedules', 'b1']], 'the empty state still opens the new-task form') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index f9f088295..87002ee7c 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: