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
2 changes: 1 addition & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }))
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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. 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: <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. 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`. |
Expand Down
2 changes: 1 addition & 1 deletion patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions patches/ds-bot/files/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }))
Expand Down
Loading