diff --git a/core/client.js b/core/client.js index 6cf02c662..d6f8d8e1a 100644 --- a/core/client.js +++ b/core/client.js @@ -12296,7 +12296,7 @@ function CopyRow({ task, roster, actions, onView, onCancel }) { ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) ]; - const [target, setTarget] = (0, import_react24.useState)(others[0]?.[0] ?? ""); + const [target, setTarget] = (0, import_react24.useState)(""); const [busy, setBusy] = (0, import_react24.useState)(false); const [error, setError] = (0, import_react24.useState)(""); const submit = (event) => { diff --git a/core/src/client/schedules.js b/core/src/client/schedules.js index 834b0c0d1..e375f1a9f 100644 --- a/core/src/client/schedules.js +++ b/core/src/client/schedules.js @@ -353,7 +353,7 @@ export function CopyRow({ task, roster, actions, onView, onCancel }) { ...roster.bots.filter(bot => bot.id !== task.botId).map(bot => [bot.id, bot.name]), ...roster.rooms.filter(room => room.id !== task.roomId).map(room => [room.id, room.name]), ] - const [target, setTarget] = useState(others[0]?.[0] ?? '') + const [target, setTarget] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const submit = (event) => { diff --git a/core/test/schedules-client.test.js b/core/test/schedules-client.test.js index 63d6af77b..c3709a00e 100644 --- a/core/test/schedules-client.test.js +++ b/core/test/schedules-client.test.js @@ -264,6 +264,28 @@ test('Copy asks for another chat and posts schedule-create with copyFrom (#1287) assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'b2', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) }) +// #1322: the picker opens on its placeholder, not on the first other chat — `others[0]` is roster +// order, so the old opening state made one click on Copy send the task to whichever Bot sorted +// first. A person has to pick a target; a submit without one sends nothing. +test('the copy picker opens with no target chosen, so one click cannot send it (#1322)', async () => { + const sent = [] + const actions = { ...spy(), scheduleCreate: async (task) => { sent.push(task); return OFF } } + const form = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + const select = all(form).find(node => node.type === 'select') + assert.equal(select.props.value, '', 'the placeholder is the opening choice, never the first other chat') + const copy = buttonsOf(form).find(button => text(button).includes('Copy')) + assert.equal(copy.props.disabled, true, 'Copy is disabled until a target is picked') + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(sent, [], 'submitting with no target sends nothing') + // The target a person picked is the one sent, and only that chat. + seeded.push('g1') + const picked = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + picked.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'g1', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) +}) + test('a task row offers Copy (#1287)', () => { seeded.push({ available: true, tasks: [ACTIVE] }, null) const card = render(BotSchedules({ bot: { id: 'b1' }, roster: OTHERS, actions: { ...spy(), scheduleCreate: async () => OFF } })) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 6fed32fe6..cb50b3803 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -35,7 +35,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**; 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. 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**; 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; the picker opens on its placeholder, not on the first other chat, so a target is picked before **Copy** can send, #1322) 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. 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`. | | `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. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | | `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2eb031443..af94f17ff 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2186,7 +2186,7 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js + ...roster.bots.filter(bot => bot.id !== task.botId).map(bot => [bot.id, bot.name]), + ...roster.rooms.filter(room => room.id !== task.roomId).map(room => [room.id, room.name]), ] -+ const [target, setTarget] = useState(others[0]?.[0] ?? '') ++ const [target, setTarget] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const submit = (event) => { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 5f858c63d..77dbe4075 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -14416,7 +14416,7 @@ diff --git a/client.js b/client.js + ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), + ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) + ]; -+ const [target, setTarget] = (0, import_react24.useState)(others[0]?.[0] ?? ""); ++ const [target, setTarget] = (0, import_react24.useState)(""); + const [busy, setBusy] = (0, import_react24.useState)(false); + const [error, setError] = (0, import_react24.useState)(""); + const submit = (event) => { diff --git a/patches/ds-bot/files/test/schedules-client.test.js b/patches/ds-bot/files/test/schedules-client.test.js index 63d6af77b..c3709a00e 100644 --- a/patches/ds-bot/files/test/schedules-client.test.js +++ b/patches/ds-bot/files/test/schedules-client.test.js @@ -264,6 +264,28 @@ test('Copy asks for another chat and posts schedule-create with copyFrom (#1287) assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'b2', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) }) +// #1322: the picker opens on its placeholder, not on the first other chat — `others[0]` is roster +// order, so the old opening state made one click on Copy send the task to whichever Bot sorted +// first. A person has to pick a target; a submit without one sends nothing. +test('the copy picker opens with no target chosen, so one click cannot send it (#1322)', async () => { + const sent = [] + const actions = { ...spy(), scheduleCreate: async (task) => { sent.push(task); return OFF } } + const form = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + const select = all(form).find(node => node.type === 'select') + assert.equal(select.props.value, '', 'the placeholder is the opening choice, never the first other chat') + const copy = buttonsOf(form).find(button => text(button).includes('Copy')) + assert.equal(copy.props.disabled, true, 'Copy is disabled until a target is picked') + form.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(sent, [], 'submitting with no target sends nothing') + // The target a person picked is the one sent, and only that chat. + seeded.push('g1') + const picked = render(h(CopyRow, { task: ACTIVE, roster: OTHERS, actions, onView() {}, onCancel() {} })) + picked.props.onSubmit({ preventDefault() {} }) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'g1', copyFrom: { sessionId: 'chat-b1', id: 's1' } }]) +}) + test('a task row offers Copy (#1287)', () => { seeded.push({ available: true, tasks: [ACTIVE] }, null) const card = render(BotSchedules({ bot: { id: 'b1' }, roster: OTHERS, actions: { ...spy(), scheduleCreate: async () => OFF } }))