From 4908e13e995b7d2e4f0d174b25f4d0b2ca9c8096 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 5 Nov 2026 22:58:22 +0800 Subject: [PATCH] Inbox: restore the queue when a card's Settings page closes Replay of this tip onto main 276042f8: the branch's core/src/client changes applied on the current main, with core/client.js and patches/ds-bot/ regenerated (npm run build, ds-bot-patches generate) rather than merged. --- changelog.d/2541-inbox-back-to-queue.fixed.md | 1 + core/client.js | 16 +- core/src/client/inbox.js | 14 +- core/src/client/index.js | 11 +- core/test/inbox-settings-back.test.js | 66 ++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 15 +- patches/ds-bot/0008-client-bundle.patch | 337 +++++++++--------- patches/ds-bot/files/src/client/inbox.js | 14 +- .../files/test/inbox-settings-back.test.js | 66 ++++ 10 files changed, 359 insertions(+), 183 deletions(-) create mode 100644 changelog.d/2541-inbox-back-to-queue.fixed.md create mode 100644 core/test/inbox-settings-back.test.js create mode 100644 patches/ds-bot/files/test/inbox-settings-back.test.js diff --git a/changelog.d/2541-inbox-back-to-queue.fixed.md b/changelog.d/2541-inbox-back-to-queue.fixed.md new file mode 100644 index 000000000..518821a47 --- /dev/null +++ b/changelog.d/2541-inbox-back-to-queue.fixed.md @@ -0,0 +1 @@ +Closing the Workflow graphs page that an Inbox card opened now brings the Inbox back, instead of leaving you in the chat under it; a Settings page opened anywhere else still closes to the view beneath it ([#2541](https://github.com/ThinkFlowLab/FlyBot/issues/2541)). diff --git a/core/client.js b/core/client.js index cabefd52d..3de5a5106 100644 --- a/core/client.js +++ b/core/client.js @@ -9327,11 +9327,16 @@ function useNow2(ms) { function openCard(actions, card) { actions.closeInbox(); if (card?.kind === "workflow") { - actions.openSettings("workflows"); + actions.openSettings("workflows", null, "inbox"); return; } actions.openSession(card.chat); } +function closedSettings(state) { + if (!state.settings && !state.remoteBotSettings) return state; + const back = state.settings?.back === "inbox"; + return { ...state, settings: null, remoteBotSettings: null, ...back ? { inbox: true } : {} }; +} function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { const work = useWork((value) => value); const sessions = useSessions((value) => value); @@ -23023,17 +23028,18 @@ function apply(ctx) { renameBot: (id) => { ui2.set((state) => ({ ...state, details: id, view: "details", renaming: true, tab: null })); }, - // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). - openSettings: (page = "general", id = null) => { + // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). `back` is where + // closing the page returns to; a page the inbox opened sends the queue back (#2541). + openSettings: (page = "general", id = null, back = null) => { const remote = id && roster.getSnapshot().byId[id]?.remote; if (page === "bots" && remote) { ui2.set((state) => ({ ...state, remoteBotSettings: id, settings: null, menu: null, palette: null, newChat: null })); return; } - ui2.set((state) => ({ ...state, settings: { page, id }, remoteBotSettings: null, menu: null, palette: null, newChat: null })); + ui2.set((state) => ({ ...state, settings: back ? { page, id, back } : { page, id }, remoteBotSettings: null, menu: null, palette: null, newChat: null })); }, closeSettings: () => { - ui2.set((state) => state.settings || state.remoteBotSettings ? { ...state, settings: null, remoteBotSettings: null } : state); + ui2.set(closedSettings); }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index de1d40793..042228a6b 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -603,15 +603,27 @@ function useNow(ms) { // Open on a card. A failed workflow run has no chat to land in, so it opens the // Workflow graphs page in settings instead (#1706); every other card keeps the chat. // The page key is `workflows` — Settings falls back to General for any key it does not list. +// The page also carries the inbox as where closing it returns, so one card read does not cost +// an Inbox reopened by hand (#2541). export function openCard(actions, card) { actions.closeInbox() if (card?.kind === 'workflow') { - actions.openSettings('workflows') + actions.openSettings('workflows', null, 'inbox') return } actions.openSession(card.chat) } +// Closing Settings goes back to where the page was opened from. A page a card opened says so +// (`back: 'inbox'`), and the inbox comes back over the conversation instead of the chat under +// it; any other page closes to the view beneath it (#2541). The shell runs exactly this +// transition, so what a test asserts here is what the close does. +export function closedSettings(state) { + if (!state.settings && !state.remoteBotSettings) return state + const back = state.settings?.back === 'inbox' + return { ...state, settings: null, remoteBotSettings: null, ...(back ? { inbox: true } : {}) } +} + export function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { const work = useWork(value => value) const sessions = useSessions(value => value) diff --git a/core/src/client/index.js b/core/src/client/index.js index 43832b380..510ffb502 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -32,7 +32,7 @@ import { GROUP_SETTINGS_CSS } from './group-settings.js' import { FOLDER_CSS } from './folder-menu.js' import { USAGE_CSS } from './usage-page.js' import { SCHEDULE_CSS } from './schedules.js' -import { INBOX_CSS } from './inbox.js' +import { INBOX_CSS, closedSettings } from './inbox.js' import { readWorkflows } from './workflows.js' import { KANBAN_CSS } from './kanban.js' import { MOBILE_CSS } from './mobile.js' @@ -506,16 +506,17 @@ export function apply(ctx) { showDetails: (id, tab = null) => { ui.set(state => ({ ...state, details: id, view: 'details', renaming: false, tab })) }, // Opening the conversation would move focus to its composer and blur the name field. renameBot: (id) => { ui.set(state => ({ ...state, details: id, view: 'details', renaming: true, tab: null })) }, - // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). - openSettings: (page = 'general', id = null) => { + // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). `back` is where + // closing the page returns to; a page the inbox opened sends the queue back (#2541). + openSettings: (page = 'general', id = null, back = null) => { const remote = id && roster.getSnapshot().byId[id]?.remote if (page === 'bots' && remote) { ui.set(state => ({ ...state, remoteBotSettings: id, settings: null, menu: null, palette: null, newChat: null })) return } - ui.set(state => ({ ...state, settings: { page, id }, remoteBotSettings: null, menu: null, palette: null, newChat: null })) + ui.set(state => ({ ...state, settings: back ? { page, id, back } : { page, id }, remoteBotSettings: null, menu: null, palette: null, newChat: null })) }, - closeSettings: () => { ui.set(state => (state.settings || state.remoteBotSettings ? { ...state, settings: null, remoteBotSettings: null } : state)) }, + closeSettings: () => { ui.set(closedSettings) }, openMemory: (botId) => { ui.set(state => ({ ...state, memory: botId, menu: null, palette: null })) }, closeMemory: () => { ui.set(state => (state.memory ? { ...state, memory: null } : state)) }, // The Agent inbox (#349) opens over the conversation; Open on a card closes it first. diff --git a/core/test/inbox-settings-back.test.js b/core/test/inbox-settings-back.test.js new file mode 100644 index 000000000..cac4f9b2a --- /dev/null +++ b/core/test/inbox-settings-back.test.js @@ -0,0 +1,66 @@ +// A failed workflow run has no chat, so Open on its inbox card opens the Workflow graphs page +// in Settings (#1706, inbox.js `openCard`). Closing that page did not come back to the queue: +// `closeSettings` only nulled the panel, so the person landed on whatever conversation lay under +// the inbox they had opened. The page a card opens now carries where closing it returns to, and +// the settings close reads that marker. `closedSettings` is the one transition the UI store runs +// for a close, so the shell and this test take the same path. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// inbox.js is a browser module: React and the icon set are the shell's, so it is bundled once with +// those left external and the platform modules stubbed, the way inbox.test.js loads it. +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/inbox.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const module = { exports: {} } +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + }, + 'react-dom': {}, + '@deepseek-ai/dsh-client-ui-primitives': {}, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +}) +const { openCard } = module.exports +const closedSettings = state => module.exports.closedSettings(state) + +const calls = [] +const actions = { + closeInbox: () => calls.push(['close']), + openSettings: (...args) => calls.push(['settings', ...args]), + openSession: id => calls.push(['session', id]), +} + +test('#2541: a workflow card asks Settings to return to the inbox when it closes', () => { + calls.length = 0 + openCard(actions, { kind: 'workflow', id: 'wf-1' }) + assert.deepEqual(calls, [['close'], ['settings', 'workflows', null, 'inbox']], 'the page names the inbox as where closing returns') +}) + +test('#2541: closing that page brings the inbox back instead of the chat under it', () => { + const opened = { inbox: null, settings: { page: 'workflows', id: null, back: 'inbox' }, remoteBotSettings: null } + const closed = closedSettings(opened) + assert.equal(closed.inbox, true, 'the inbox is open again') + assert.equal(closed.settings, null) +}) + +test('#2541: a Settings page opened any other way still closes to the view under it', () => { + const closed = closedSettings({ inbox: null, settings: { page: 'workflows', id: null }, remoteBotSettings: null }) + assert.equal(closed.inbox, null, 'no inbox comes back that was not open before') + assert.equal(closed.settings, null) + const untouched = { inbox: true, settings: null, remoteBotSettings: null } + assert.equal(closedSettings(untouched), untouched, 'a close with no panel open leaves the state alone') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e4151d118..9f85ab3cf 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -49,7 +49,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `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**, and any other task the host will run on demand — active, with no run in flight — gets **Run now**; both call the same `actions.scheduleRetry` and run the task once now without moving DSH's own next time (#473, #2507). 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, as a `role="alert"` so it is announced and not only printed (#2232)). 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: