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
1 change: 1 addition & 0 deletions changelog.d/1334-taskform-no-preselect.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The new-task form in Scheduled tasks no longer opens with the first Bot already chosen: the **Send to** picker starts on "Pick a Bot or group chat", and **Create task** stays disabled until a Bot or group chat is picked, so a task can no longer be created for whoever sorts first on one click ([#1334](https://github.com/ThinkFlowLab/FlyBot/issues/1334)).
5 changes: 3 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11672,7 +11672,7 @@ function formStart(task) {
}
function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
const first = formStart(task);
const [target, setTarget] = (0, import_react23.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? "");
const [target, setTarget] = (0, import_react23.useState)(task ? task.botId ?? task.roomId : start ?? "");
const [title, setTitle] = (0, import_react23.useState)(first.title);
const [prompt, setPrompt] = (0, import_react23.useState)(first.prompt);
const [kind, setKind] = (0, import_react23.useState)(first.kind);
Expand All @@ -11691,7 +11691,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
};
const submit = (event) => {
event.preventDefault();
if (busy) return;
if (busy || target === "") return;
setBusy(true);
setError("");
let when3;
Expand Down Expand Up @@ -11719,6 +11719,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
(0, import_react23.createElement)(
"select",
{ className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
(0, import_react23.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
(0, import_react23.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react23.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
roster.rooms.length > 0 ? (0, import_react23.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react23.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
)
Expand Down
7 changes: 5 additions & 2 deletions core/src/client/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ export function formStart(task) {
function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
// A task goes to a Bot's chat or to a group chat, where it reads as your message.
const first = formStart(task)
const [target, setTarget] = useState(task ? (task.botId ?? task.roomId) : start ?? roster.bots[0]?.id ?? '')
// No default target (#1334): roster order is not a choice, and a form that opened on
// `roster.bots[0]` created the task for whoever sorted first on one click.
const [target, setTarget] = useState(task ? (task.botId ?? task.roomId) : start ?? '')
const [title, setTitle] = useState(first.title)
const [prompt, setPrompt] = useState(first.prompt)
const [kind, setKind] = useState(first.kind)
Expand All @@ -196,7 +198,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
}
const submit = (event) => {
event.preventDefault()
if (busy) return
if (busy || target === '') return
setBusy(true)
setError('')
let when
Expand All @@ -211,6 +213,7 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
h('label', { className: 'bt-set-row bt-bs-row' },
h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Send to'))),
h('select', { className: 'bt-select bt-task-bot', value: target, onChange: event => setTarget(event.target.value) },
h('option', { value: '' }, t('Pick a Bot or group chat')),
h('optgroup', { label: t('Bots') }, roster.bots.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))),
roster.rooms.length > 0 ? h('optgroup', { label: t('Group chats') }, roster.rooms.map(room => h('option', { key: room.id, value: room.id }, room.name))) : null)),
h('label', { className: 'bt-set-row bt-bs-row' },
Expand Down
32 changes: 32 additions & 0 deletions core/test/schedules-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,38 @@ test('the copy picker opens with no target chosen, so one click cannot send it (
assert.deepEqual(JSON.parse(JSON.stringify(sent)), [{ target: 'g1', copyFrom: { sessionId: 'chat-b1', id: 's1' } }])
})

// #1334: the new-task form opens with no Bot chosen — `roster.bots[0]` is roster order, so the
// old opening state created the task for whichever Bot sorted first on one click. A person has to
// pick a target; a submit without one sends nothing.
test('the new-task form opens with no target chosen, so one click cannot create it (#1334)', async () => {
const sent = []
const actions = { ...spy(), scheduleCreate: async (task) => { sent.push(task); return OFF } }
// The shim hands states out in hook order: the page's view, `adding` (the form is open),
// `editing`, then QuietHoursCard's four. The form's own target is left unseeded on purpose —
// this reads the state the form really opens with.
seeded.push({ available: true, tasks: [] }, true, null, false, '', '22:00', '07:00')
const page = render(SchedulesPage({ roster: OTHERS, actions, start: null }))
const form = all(page).find(node => node.type === 'form')
assert.ok(form, 'the new-task form is open')
const select = all(form).find(node => node.type === 'select')
assert.equal(select.props.value, '', 'the placeholder is the opening choice, never the first Bot')
const first = all(select).filter(node => node.type === 'option')[0]
assert.equal(first.props.value, '', 'the first option is the placeholder')
assert.deepEqual(text(first), ['Pick a Bot or group chat'])
const create = buttonsOf(form).find(button => text(button).includes('Create task'))
assert.equal(create.props.disabled, true, 'Create 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, with the rest of the form's values.
seeded.push({ available: true, tasks: [] }, true, null, false, '', '22:00', '07:00', 'g1', 'Briefing', 'Sum up')
const picked = render(SchedulesPage({ roster: OTHERS, actions, start: null }))
all(picked).find(node => node.type === 'form').props.onSubmit({ preventDefault() {} })
await new Promise(resolve => setImmediate(resolve))
assert.equal(sent.length, 1)
assert.deepEqual([sent[0].target, sent[0].title, sent[0].prompt, sent[0].when.kind], ['g1', 'Briefing', 'Sum up', 'daily'])
})

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 @@ -37,7 +37,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. 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. 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**; 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: <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. 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. 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. Since #1347 the error goes through `failureSentence` (`core/src/shared/failure-copy.js`), which turns a provider refusal stored as a cut-off JSON body into a sentence that names the feature and where to change the model; the Inbox reads the same helper. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`, `core/test/ui-clickthrough.test.js`. |
| `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `<untrusted-message>` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. |
Expand Down
Loading
Loading