diff --git a/changelog.d/2039-room-schedules.fixed.md b/changelog.d/2039-room-schedules.fixed.md new file mode 100644 index 000000000..038d84663 --- /dev/null +++ b/changelog.d/2039-room-schedules.fixed.md @@ -0,0 +1 @@ +A group chat's details panel now lists the scheduled tasks that room owns. Before, a task could target a group chat — the new-task form offered `Group chats`, and Settings grouped tasks by room — but the room itself showed nothing, so "is the 9am task on this room still there?" meant leaving the room for Settings. The panel draws the three newest of the room's tasks, a title carrying the real count and `N more not shown` past three, and only tasks that carry this room's `roomId` and no `botId`: a Bot's task and another room's task stay out. Rows open the settings list, a room with no tasks draws nothing at all, and a face-to-face room gets no such section ([#2039](https://github.com/ThinkFlowLab/FlyBot/issues/2039)). diff --git a/core/client.js b/core/client.js index 4a7655cce..f14f90cc0 100644 --- a/core/client.js +++ b/core/client.js @@ -13508,17 +13508,14 @@ function BotSchedules({ bot, roster, actions }) { }) ); } -function ScheduleSummary({ bot, actions }) { - const view = useSchedules(actions); - if (view === null) return null; - const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; +function SummaryLines({ tasks, actions }) { const shown = tasks.slice(0, 3); const hidden = tasks.length - shown.length; return (0, import_react25.createElement)( - "div", + import_react25.Fragment, null, (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)( + (0, import_react25.createElement)( "div", { className: "bt-task-list" }, shown.map((task) => (0, import_react25.createElement)( @@ -13532,6 +13529,24 @@ function ScheduleSummary({ bot, actions }) { ) ); } +function ScheduleSummary({ bot, actions }) { + const view = useSchedules(actions); + if (view === null) return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; + return (0, import_react25.createElement)( + "div", + null, + tasks.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")) : (0, import_react25.createElement)(SummaryLines, { tasks, actions }), + 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")) : null + ); +} +function RoomSchedules({ room: room2, actions }) { + const view = useSchedules(actions); + if (view === null || room2.origin === "face-to-face") return null; + const tasks = view.available ? view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id) : []; + if (tasks.length === 0) return null; + return (0, import_react25.createElement)("div", null, (0, import_react25.createElement)(SummaryLines, { tasks, actions })); +} var SCHEDULE_CSS = ` .bt-task{gap:10px} .bt-task-copy{flex-wrap:wrap;gap:8px} @@ -19466,7 +19481,8 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti { key: "usage" }, (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")), (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, room2.name), (0, import_react44.createElement)("span", null, usage)) - ) + ), + (0, import_react44.createElement)(RoomSchedules, { key: "routines", room: room2, actions }) ]; } function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) { diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 7997141b6..cc513c708 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -10,7 +10,7 @@ import { FaceLead, FacePersonRow, GroupSettingsForm, ROOM_MODES, facePeople, roo import { ModelMenu, modelOf } from './model-menu.js' import { FolderMenu } from './folder-menu.js' import { MemoryRow } from './memory-panel.js' -import { ScheduleSummary } from './schedules.js' +import { RoomSchedules, ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' @@ -211,6 +211,7 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) h('div', { className: 'bt-kv' }, h('span', null, t(ROOM_MODES.find(([id]) => id === shownMode(room, roster))?.[1] ?? 'Everyone')))), face || !usage ? null : h('div', { key: 'usage' }, h('div', { className: 'bt-section-title' }, t('Usage')), h('div', { className: 'bt-kv' }, h('span', null, room.name), h('span', null, usage))), + h(RoomSchedules, { key: 'routines', room, actions }), ] } diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index ef3e839d1..5a6d5fe3c 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -416,11 +416,24 @@ export function BotSchedules({ bot, roster, actions }) { })) } +// The panel's rows: three of them, a title carrying the real count above three, and one row +// saying what the cap hid (#2030). A Bot's details line and a group chat's section share it, so +// the two surfaces cannot describe the same list differently. +function SummaryLines({ tasks, actions }) { + const shown = tasks.slice(0, 3) + const hidden = tasks.length - shown.length + return h(Fragment, null, + h('div', { className: 'bt-section-title' }, hidden > 0 ? t('Scheduled tasks · {count}', { count: tasks.length }) : t('Scheduled tasks')), + 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)) +} + // 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). +// The rows open 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) // #2035: the shared view starts null and the first read lands a frame later, so "no view yet" is @@ -429,16 +442,26 @@ export function ScheduleSummary({ bot, actions }) { // 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, - 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' }, - 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)) + ? h('div', { className: 'bt-section-title' }, t('Scheduled tasks')) + : h(SummaryLines, { tasks, actions }), + 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')) + : null) +} + +// A group chat's own tasks in its details panel (#2039). A task belongs to a room when it carries +// that room's `roomId` and no `botId`; a Bot's task in the same view and another room's task are +// both someone else's and stay out. Nothing is drawn for a room with no tasks — the rooms' page +// keeps the only "none" sentence — and a face-to-face room has no such section. The rows open the +// settings list with no target, because a room id here would open the new-task form (#2030). +export function RoomSchedules({ room, actions }) { + const view = useSchedules(actions) + if (view === null || room.origin === 'face-to-face') return null + const tasks = view.available ? view.tasks.filter(task => task.botId === undefined && task.roomId === room.id) : [] + if (tasks.length === 0) return null + return h('div', null, h(SummaryLines, { tasks, actions })) } export const SCHEDULE_CSS = ` diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index a9eb147bf..b9f9441c0 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -9,7 +9,9 @@ import { build } from 'esbuild' // where the switch is; the Harness settings dialog has none. const { outputFiles } = await build({ stdin: { - contents: "export { SchedulesPage, BotSchedules, ScheduleSummary, CopyRow, openPlugins, formStart, timingText } from './src/client/schedules.js'", + // A star re-export keeps a missing name a runtime `undefined` in one test instead of a build + // failure that would hide which case is red (#2039). + contents: "export * from './src/client/schedules.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -31,7 +33,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SchedulesPage, BotSchedules, ScheduleSummary, CopyRow, openPlugins, formStart, timingText } = module.exports +const { SchedulesPage, BotSchedules, ScheduleSummary, RoomSchedules, CopyRow, openPlugins, formStart, timingText } = module.exports // Every node and string under a tree; an array (what a page returns) is walked too. const all = (node, out = []) => { if (Array.isArray(node)) { for (const item of node) all(item, out); return out } @@ -523,3 +525,58 @@ test('#2035: five tasks keep the #2030 shape — count in the title, three rows, more[0].props.onClick() assert.deepEqual(actions.calls, [['schedules'], ['schedules']], 'rows and the more row open the list, never the form') }) + +// #2039: a scheduled task can belong to a group chat, but the room's own details panel had no +// section for them — the only place to see them was Settings. The panel now lists the tasks the +// room owns, in the same shape the Bot line uses. The control cases matter: a task owned by a Bot +// and a task owned by another room sit in the same view and must not appear, so a filter written +// as "tasks without a botId" cannot pass by drawing everything. +const ROOM = { id: 'r1', name: 'Team room', origin: 'group', members: [] } +const FACE_ROOM = { id: 'f1', name: 'Face room', origin: 'face-to-face', members: [] } +const roomTasks = () => fiveTasks().map((task, index) => ({ ...task, id: `r${index}`, sessionId: 'chat-r1', botId: undefined, roomId: 'r1', title: `Room task ${index + 1}` })) +const BOT_TASK = { ...ACTIVE, id: 'b-bot', title: 'Bot task', sessionId: 'chat-b1', botId: 'b1', roomId: 'r1' } +const OTHER_ROOM_TASK = { ...ACTIVE, id: 'r-other', title: 'Other room task', sessionId: 'chat-r2', botId: undefined, roomId: 'r2' } + +test("#2039: a group chat's panel lists only the tasks that room owns", () => { + const actions = opened() + seeded.push({ available: true, tasks: [...roomTasks(), BOT_TASK, OTHER_ROOM_TASK] }) + const panel = render(RoomSchedules({ room: ROOM, actions })) + assert.deepEqual(text(sectionTitle(panel)[0]), ['Scheduled tasks · 5'], "the count is the room's five, not the seven the view holds") + const rows = buttonsOf(panel).filter(button => button.props.className === 'bt-task-line') + assert.equal(rows.length, 3, 'the cap still draws three rows') + const lines = text(panel) + assert.ok(!lines.includes('Bot task'), `a Bot's task must not appear in a room: ${lines.join('|')}`) + assert.ok(!lines.includes('Other room task'), `another room's task must not appear: ${lines.join('|')}`) + assert.deepEqual(text(buttonsOf(panel).filter(button => button.props.className === 'bt-more')[0]), ['2 more not shown']) +}) + +test("#2039: a row and the more row in a room's panel open the list with no target", () => { + const actions = opened() + seeded.push({ available: true, tasks: roomTasks() }) + const panel = render(RoomSchedules({ room: ROOM, actions })) + const rows = buttonsOf(panel).filter(button => button.props.className === 'bt-task-line') + const more = buttonsOf(panel).filter(button => button.props.className === 'bt-more') + rows[0].props.onClick() + more[0].props.onClick() + assert.deepEqual(actions.calls, [['schedules'], ['schedules']], 'the list, never the new-task form: no room id and no Bot id') +}) + +test('#2039: a room with no tasks draws nothing at all', () => { + const actions = opened() + seeded.push({ available: true, tasks: [BOT_TASK, OTHER_ROOM_TASK] }) + assert.equal(RoomSchedules({ room: ROOM, actions }), null, 'no section, no empty state, no add button') + assert.deepEqual(actions.calls, []) +}) + +test('#2039: a face-to-face room draws no schedule section', () => { + const actions = opened() + // The tasks here are the face room's own, so only the face-room rule can keep the section out. + seeded.push({ available: true, tasks: roomTasks().map(task => ({ ...task, roomId: 'f1', sessionId: 'chat-f1' })) }) + assert.equal(RoomSchedules({ room: FACE_ROOM, actions }), null, 'a face room has no such section') +}) + +test('#2039: an unread schedules view draws no room section either', () => { + const actions = opened() + seeded.push(null) + assert.equal(RoomSchedules({ room: ROOM, actions }), null, 'no view yet is not "this room has no tasks"') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a7b75ce1c..ee18772ed 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: