diff --git a/changelog.d/469-waiting-reminder-toggles.added.md b/changelog.d/469-waiting-reminder-toggles.added.md new file mode 100644 index 00000000..24badeac --- /dev/null +++ b/changelog.d/469-waiting-reminder-toggles.added.md @@ -0,0 +1 @@ +Notifications go further: an approval that is still waiting after 15 minutes gets one reminder (`Coder has waited 15 min for your approval`), Settings → General → **Notifications** has a separate switch for waiting approvals, finished turns and failed turns, and each chat keeps a single notification that a newer one replaces and that counts its updates ([#469](https://github.com/ThinkFlowLab/FlyBot/issues/469)). diff --git a/core/client.js b/core/client.js index 06dd4ffd..28d26ef6 100644 --- a/core/client.js +++ b/core/client.js @@ -711,6 +711,9 @@ var ZH = { "{name} is waiting for your approval": "{name} 等你批准", "{name} finished": "{name} 已完成", "{name} failed: {reason}": "{name} 失败:{reason}", + "{name} has waited {n} min for your approval": "{name} 已经等你批准 {n} 分钟了", + "{count} updates": "{count} 条更新", + "Once more if it still waits after 15 min.": "等了 15 分钟还没批准时再提醒一次。", Notifications: "通知", "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", @@ -13767,6 +13770,11 @@ function SetRow({ label, hint, children }) { children ? (0, import_react33.createElement)("div", { className: "bt-set-control" }, children) : null ); } +var NOTIFY_KINDS = [ + ["approval", "Waiting for approval", "Once more if it still waits after 15 min."], + ["finished", "Finished"], + ["failed", "Failed"] +]; function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {}; const themes = allThemes(); @@ -13817,7 +13825,7 @@ function GeneralPage({ roster, actions, run }) { ) ) ), - // One switch for every system notification: approvals waiting, Bots finished or failed (#353). + // One switch for every system notification (#353), then one per kind (#469). (0, import_react33.createElement)( SetSection, { key: "notify", title: t("Notifications") }, @@ -13827,7 +13835,15 @@ function GeneralPage({ roster, actions, run }) { hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), on: prefs.notifications !== false, onChange: (on) => run(actions.setPrefs({ notifications: on })) - }) + }), + NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react33.createElement)(SwitchRow, { + key: kind, + label: t(label), + hint: hint ? t(hint) : void 0, + disabled: prefs.notifications === false, + on: prefs.notify?.[kind] !== false, + onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } })) + })) ) ]; } @@ -16713,46 +16729,71 @@ var USER_MESSAGE_CSS = ` `; // src/client/approval-notices.js -function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { +var WAITING_TOO_LONG = 15 * 60 * 1e3; +function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now(), now = () => Date.now(), timers = globalThis }) { const told = /* @__PURE__ */ new Set(); + const unseen = /* @__PURE__ */ new Map(); + let timer; const notify = () => { + timers.clearTimeout?.(timer); + timer = void 0; const { approvals = [], ended = [] } = work.getSnapshot(); + const at = now(); + const lateAt = (entry) => Math.max(entry.at ?? 0, since2) + WAITING_TOO_LONG; const events = [ - ...approvals.map((entry) => ({ entry, kind: "approval" })), - ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) + ...approvals.map((entry) => ({ entry, kind: "approval", key: `approval-${entry.id}`, when: entry.at ?? 0 })), + ...approvals.filter((entry) => lateAt(entry) <= at).map((entry) => ({ entry, kind: "late", key: `late-${entry.id}`, when: lateAt(entry) })), + ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed", key: `turn-${entry.id}`, when: entry.at ?? 0 })) ]; - const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; - const current = new Set(events.map(key)); + const current = new Set(events.map((event) => event.key)); for (const id of told) { if (!current.has(id)) told.delete(id); } + const next = Math.min(...approvals.map(lateAt).filter((due) => due > at)); + if (Number.isFinite(next)) timer = timers.setTimeout?.(notify, next - at); if (typeof Notification !== "function" || Notification.permission !== "granted") return; const { byId, roomsById, prefs } = roster.getSnapshot(); const on = prefs?.notifications !== false; + const kinds = prefs?.notify ?? {}; + const visible = document.visibilityState === "visible"; + const shown = shownSession(); + if (visible) unseen.delete(shown); + const fresh = /* @__PURE__ */ new Map(); for (const event of events) { - const id = key(event); - if (told.has(id)) continue; - told.add(id); + if (told.has(event.key)) continue; + told.add(event.key); const { entry, kind } = event; - if (!on || (entry.at ?? 0) < since2) continue; + if (!on || kinds[kind === "late" ? "approval" : kind] === false) continue; + if (kind !== "late" && (entry.at ?? 0) < since2) continue; const place = entry.roomId ?? entry.sessionId; - if (document.visibilityState === "visible" && shownSession() === place) continue; + if (visible && shown === place) continue; + fresh.set(place, [...fresh.get(place) ?? [], event]); + } + for (const [place, list] of fresh) { + const { entry, kind } = list.reduce((last, event) => event.when >= last.when ? event : last); + const count = (unseen.get(place) ?? 0) + list.filter((event) => event.kind !== "late").length; + unseen.set(place, count); const room2 = entry.roomId ? roomsById[entry.roomId] : void 0; const name = byId[entry.botId]?.name ?? t("A Bot"); - const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); + const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "late" ? t("{name} has waited {n} min for your approval", { name, n: Math.max(15, Math.floor((at - (entry.at ?? at)) / 6e4)) }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); const note = new Notification(title, { - body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), - tag: `ds-bot-${id}` + body: [room2?.name, kind === "approval" || kind === "late" ? entry.tool : void 0, count > 1 ? t("{count} updates", { count }) : void 0].filter(Boolean).join(" · "), + tag: `ds-bot-${place}` }); note.onclick = () => { window.focus(); + unseen.delete(place); openSession(entry.roomId ?? entry.botId); note.close(); }; } }; notify(); - return work.subscribe(notify); + const off = work.subscribe(notify); + return () => { + off(); + timers.clearTimeout?.(timer); + }; } // src/client/stats-dock.js diff --git a/core/src/client/approval-notices.js b/core/src/client/approval-notices.js index a1754d36..520c6638 100644 --- a/core/src/client/approval-notices.js +++ b/core/src/client/approval-notices.js @@ -1,52 +1,82 @@ import { t } from './i18n.js' -// A system notification for each approval that starts waiting, and each turn of the user's -// own Bots that finishes or fails (#353), while this window is in the background or shows -// another conversation. Only where the platform already allows notifications: DS Bot never -// asks for the permission. Settings → General → Notifications turns them all off. A -// notification only opens its chat: it never approves, rejects or sends anything. -export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now() }) { +// An approval still waiting after this long gets one more reminder (#469, owner decision). +export const WAITING_TOO_LONG = 15 * 60 * 1000 + +// A system notification for each approval that starts waiting, one more when it has waited +// too long (#469), and each turn of the user's own Bots that finishes or fails (#353), while +// this window is in the background or shows another conversation. Only where the platform +// already allows notifications: DS Bot never asks for the permission. Settings → General → +// Notifications turns them all off, or each kind on its own (`prefs.notify`, #469). One chat +// keeps one notification: a newer one replaces it and counts the updates since the chat was +// last open. A notification only opens its chat: it never approves, rejects or sends anything. +export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now(), now = () => Date.now(), timers = globalThis }) { const told = new Set() + const unseen = new Map() + let timer const notify = () => { + timers.clearTimeout?.(timer) + timer = undefined const { approvals = [], ended = [] } = work.getSnapshot() + const at = now() + const lateAt = entry => Math.max(entry.at ?? 0, since) + WAITING_TOO_LONG const events = [ - ...approvals.map(entry => ({ entry, kind: 'approval' })), - ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), + ...approvals.map(entry => ({ entry, kind: 'approval', key: `approval-${entry.id}`, when: entry.at ?? 0 })), + ...approvals.filter(entry => lateAt(entry) <= at).map(entry => ({ entry, kind: 'late', key: `late-${entry.id}`, when: lateAt(entry) })), + ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed', key: `turn-${entry.id}`, when: entry.at ?? 0 })), ] - const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` - const current = new Set(events.map(key)) + const current = new Set(events.map(event => event.key)) for (const id of told) { if (!current.has(id)) told.delete(id) } + const next = Math.min(...approvals.map(lateAt).filter(due => due > at)) + if (Number.isFinite(next)) timer = timers.setTimeout?.(notify, next - at) if (typeof Notification !== 'function' || Notification.permission !== 'granted') return const { byId, roomsById, prefs } = roster.getSnapshot() const on = prefs?.notifications !== false + const kinds = prefs?.notify ?? {} + const visible = document.visibilityState === 'visible' + const shown = shownSession() + if (visible) unseen.delete(shown) + const fresh = new Map() for (const event of events) { - const id = key(event) - if (told.has(id)) continue - told.add(id) + if (told.has(event.key)) continue + told.add(event.key) const { entry, kind } = event // Waiting or ending before this window opened is no news: the sidebar shows it. // Switched off, it stays told, so switching back on brings no old ones. - if (!on || (entry.at ?? 0) < since) continue + if (!on || kinds[kind === 'late' ? 'approval' : kind] === false) continue + if (kind !== 'late' && (entry.at ?? 0) < since) continue const place = entry.roomId ?? entry.sessionId - if (document.visibilityState === 'visible' && shownSession() === place) continue + if (visible && shown === place) continue + fresh.set(place, [...fresh.get(place) ?? [], event]) + } + for (const [place, list] of fresh) { + const { entry, kind } = list.reduce((last, event) => (event.when >= last.when ? event : last)) + const count = (unseen.get(place) ?? 0) + list.filter(event => event.kind !== 'late').length + unseen.set(place, count) const room = entry.roomId ? roomsById[entry.roomId] : undefined const name = byId[entry.botId]?.name ?? t('A Bot') const title = kind === 'approval' ? t('{name} is waiting for your approval', { name }) - : kind === 'finished' ? t('{name} finished', { name }) - : t('{name} failed: {reason}', { name, reason: entry.failed }) + : kind === 'late' ? t('{name} has waited {n} min for your approval', { name, n: Math.max(15, Math.floor((at - (entry.at ?? at)) / 60000)) }) + : kind === 'finished' ? t('{name} finished', { name }) + : t('{name} failed: {reason}', { name, reason: entry.failed }) const note = new Notification(title, { - body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), - tag: `ds-bot-${id}`, + body: [room?.name, kind === 'approval' || kind === 'late' ? entry.tool : undefined, count > 1 ? t('{count} updates', { count }) : undefined].filter(Boolean).join(' · '), + tag: `ds-bot-${place}`, }) note.onclick = () => { window.focus() + unseen.delete(place) openSession(entry.roomId ?? entry.botId) note.close() } } } notify() - return work.subscribe(notify) + const off = work.subscribe(notify) + return () => { + off() + timers.clearTimeout?.(timer) + } } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 2f349e39..73f61abf 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -655,6 +655,9 @@ const ZH = { '{name} is waiting for your approval': '{name} 等你批准', '{name} finished': '{name} 已完成', '{name} failed: {reason}': '{name} 失败:{reason}', + '{name} has waited {n} min for your approval': '{name} 已经等你批准 {n} 分钟了', + '{count} updates': '{count} 条更新', + 'Once more if it still waits after 15 min.': '等了 15 分钟还没批准时再提醒一次。', Notifications: '通知', 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 93bbb4d5..310275a3 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -36,6 +36,12 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } +// The notification kinds with a switch of their own (#469). +const NOTIFY_KINDS = [ + ['approval', 'Waiting for approval', 'Once more if it still waits after 15 min.'], + ['finished', 'Finished'], + ['failed', 'Failed'], +] // General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} @@ -63,13 +69,17 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), - // One switch for every system notification: approvals waiting, Bots finished or failed (#353). + // One switch for every system notification (#353), then one per kind (#469). h(SetSection, { key: 'notify', title: t('Notifications') }, h(SwitchRow, { label: t('Notifications'), showLabel: false, hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), - })), + }), + NOTIFY_KINDS.map(([kind, label, hint]) => h(SwitchRow, { + key: kind, label: t(label), hint: hint ? t(hint) : undefined, disabled: prefs.notifications === false, + on: prefs.notify?.[kind] !== false, onChange: on => run(actions.setPrefs({ notify: { [kind]: on } })), + }))), ] } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 9589ef6b..6647e673 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -7,6 +7,7 @@ import { isFaceRoom } from './room-membership.js' import { faceMentions } from './face-mentions.js' import { unwrapUntrusted } from './delivery.js' +const NOTIFY_KINDS = ['approval', 'finished', 'failed'] const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -176,6 +177,19 @@ export function install(rt) { else if (body.notifications === false) state.prefs.notifications = false else throw invalid('Notifications must be on or off') } + // Each kind is on unless switched off on its own; only the off ones are kept (#469). + if (body.notify !== undefined) { + const kinds = body.notify + if (kinds === null || typeof kinds !== 'object' || Array.isArray(kinds)) throw invalid('Notifications must be on or off') + const notify = { ...state.prefs.notify } + for (const [kind, on] of Object.entries(kinds)) { + if (!NOTIFY_KINDS.includes(kind) || typeof on !== 'boolean') throw invalid('Notifications must be on or off') + if (on) delete notify[kind] + else notify[kind] = false + } + if (Object.keys(notify).length > 0) state.prefs.notify = notify + else delete state.prefs.notify + } return rt.save() } diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index a05c7d60..424b54d4 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -35,7 +35,9 @@ function windowWith({ permission = 'granted', visibility = 'visible', prefs = {} document: { visibilityState: visibility }, window: { focus() {} }, }) - return { ...module.exports, shown, opened, work, roster, openSession: id => opened.push(id) } + // Toy timestamps: the clock stays at 1000 unless a test brings its own. + const noticeApprovals = options => module.exports.noticeApprovals({ now: () => 1000, ...options }) + return { ...module.exports, noticeApprovals, shown, opened, work, roster, openSession: id => opened.push(id) } } test('an approval that starts waiting while another conversation shows gets one notification', () => { @@ -43,7 +45,7 @@ test('an approval that starts waiting while another conversation shows gets one page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) page.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 200 }] }) page.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 200 }] }) - assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer is waiting for your approval', 'Ops · pwsh', 'ds-bot-approval-x1']]) + assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer is waiting for your approval', 'Ops · pwsh', 'ds-bot-r1']]) page.shown[0].onclick() assert.deepEqual([page.opened, page.shown[0].closed], [['r1'], true]) }) @@ -76,8 +78,8 @@ test('a turn of my own Bot that finishes or fails elsewhere gets one notificatio page.work.set(ended([done, broke])) page.work.set(ended([done, broke])) assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [ - ['Writer finished', '', 'ds-bot-turn-chat-b:1:200'], - ['Writer failed: upstream busy', 'Ops', 'ds-bot-turn-g:2:300'], + ['Writer finished', '', 'ds-bot-chat-b'], + ['Writer failed: upstream busy', 'Ops', 'ds-bot-r1'], ]) page.shown[0].onclick() page.shown[1].onclick() @@ -111,7 +113,93 @@ test('the Notifications switch turns off every kind, and switching it back on br assert.equal(page.shown.length, 0) page.roster.prefs = {} page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'chat-b', at: 300 }] }) - assert.deepEqual(page.shown.map(note => note.tag), ['ds-bot-turn-e2']) + assert.deepEqual(page.shown.map(note => [note.title, note.body]), [['Writer finished', '']]) +}) + +// A clock and timers the test moves by hand. +function clock(start) { + let now = start + const pending = new Map() + let seq = 0 + return { + now: () => now, + timers: { + setTimeout: (fn, ms) => { pending.set(++seq, { fn, at: now + ms }); return seq }, + clearTimeout: id => pending.delete(id), + }, + pending, + advance(ms) { + now += ms + for (const [id, { fn, at }] of [...pending]) { + if (at <= now) { pending.delete(id); fn() } + } + }, + } +} + +const MIN = 60 * 1000 + +test('an approval still waiting after 15 min gets one more notification; one already decided gets none (#469)', () => { + const page = windowWith({ visibility: 'hidden' }) + const time = clock(1000) + const stop = page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100, now: time.now, timers: time.timers }) + const waiting = { id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 1000 } + const decided = { id: 'x2', botId: 'b', sessionId: 'b', tool: 'write', at: 1000 } + page.work.set({ bots: {}, approvals: [waiting, decided] }) + assert.equal(page.shown.length, 2) + page.work.set({ bots: {}, approvals: [waiting] }) + time.advance(14 * MIN) + assert.equal(page.shown.length, 2) + time.advance(MIN) + assert.deepEqual(page.shown.slice(2).map(note => [note.title, note.body, note.tag]), [['Writer has waited 15 min for your approval', 'Ops · pwsh', 'ds-bot-r1']]) + time.advance(60 * MIN) + page.work.set({ bots: {}, approvals: [waiting] }) + assert.equal(page.shown.length, 3) + stop() + assert.equal(time.pending.size, 0) +}) + +test('an approval that waited since before the window opened is reminded 15 min after it opened', () => { + const page = windowWith({ visibility: 'hidden' }) + const time = clock(50 * MIN) + const old = { id: 'x1', botId: 'b', sessionId: 'b', tool: 'pwsh', at: MIN } + page.work.set({ bots: {}, approvals: [old] }) + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 50 * MIN, now: time.now, timers: time.timers }) + assert.equal(page.shown.length, 0) + time.advance(15 * MIN) + assert.deepEqual(page.shown.map(note => note.title), ['Writer has waited 64 min for your approval']) +}) + +test('each kind has its own switch; off for one kind keeps the others (#469)', () => { + const page = windowWith({ visibility: 'hidden', prefs: { notify: { approval: false, finished: false } } }) + const time = clock(1000) + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100, now: time.now, timers: time.timers }) + page.work.set({ + bots: {}, + approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 1000 }], + ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 1000 }, { id: 'e2', botId: 'b', sessionId: 'chat-c', at: 1000, failed: 'no model' }], + }) + time.advance(20 * MIN) + assert.deepEqual(page.shown.map(note => note.title), ['Writer failed: no model']) +}) + +test('notifications from one chat collapse into one that counts the updates until the chat is opened (#469)', () => { + let onScreen = 'a' + const page = windowWith() + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => onScreen, openSession: page.openSession, since: 100 }) + const turn = (id, at, extra) => ({ id, botId: 'b', roomId: 'r1', at, ...extra }) + page.work.set(ended([turn('e1', 200), turn('e2', 300, { failed: 'busy' })])) + assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer failed: busy', 'Ops · 2 updates', 'ds-bot-r1']]) + page.work.set(ended([turn('e1', 200), turn('e2', 300, { failed: 'busy' }), turn('e3', 400)])) + assert.deepEqual(page.shown.slice(1).map(note => [note.title, note.body, note.tag]), [['Writer finished', 'Ops · 3 updates', 'ds-bot-r1']]) + onScreen = 'r1' + page.work.set(ended([turn('e3', 400)])) + onScreen = 'a' + page.work.set(ended([turn('e3', 400), turn('e4', 500)])) + assert.deepEqual(page.shown.slice(2).map(note => [note.title, note.body]), [['Writer finished', 'Ops']]) + page.shown[2].onclick() + page.work.set(ended([turn('e4', 500), turn('e5', 600)])) + assert.deepEqual(page.shown.slice(3).map(note => note.body), ['Ops']) }) // The host side: which ended turns the window hears of. @@ -175,3 +263,10 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske assert.equal(rt.endedView().length, 20) assert.ok(rt.endedView().every(entry => entry.failed === undefined)) }) + +test('Settings → General has one switch per kind under the main one, off while the main one is off (#469)', async () => { + const { readFileSync } = await import('node:fs') + const settings = readFileSync(new URL('../src/client/settings.js', import.meta.url), 'utf8') + assert.match(settings, /\['approval', 'Waiting for approval', 'Once more if it still waits after 15 min\.'\],\n {2}\['finished', 'Finished'\],\n {2}\['failed', 'Failed'\],/) + assert.match(settings, /disabled: prefs\.notifications === false,\n\s*on: prefs\.notify\?\.\[kind\] !== false, onChange: on => run\(actions\.setPrefs\(\{ notify: \{ \[kind\]: on \} \}\)\)/) +}) diff --git a/core/test/install.test.js b/core/test/install.test.js index 7c384212..add971d0 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -148,6 +148,13 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) + // Each kind on its own; only the off ones are kept (#469). + assert.deepEqual((await call('set-prefs', { notify: { finished: false } })).value.notify, { finished: false }) + assert.deepEqual((await call('set-prefs', { notify: { approval: false } })).value.notify, { finished: false, approval: false }) + assert.deepEqual((await call('set-prefs', { notify: { finished: true, approval: true } })).value.notify, undefined) + for (const notify of [{ loud: false }, { failed: 'no' }, ['approval'], null]) { + assert.deepEqual((await call('set-prefs', { notify })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) + } // A refusal from the team code keeps its message for the user. assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) // Off the Desktop profile there are no Bot browsers. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0172ce57..0467aebd 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -123,7 +123,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### System notifications (#353) -- `approval-notices.js` `noticeApprovals` raises one system notification per event while the window is in the background or shows another chat: an approval that starts waiting (`{name} is waiting for your approval`), and a turn of one of the user's own Bots that finished (`{name} finished`) or failed (`{name} failed: `), from `work.ended` (`turns.js` `endedView`: the last 20, process-local; no friend's Bot, no turn a friend started, no hidden turn another Bot asked for, no face-to-face or inbound-share room, no turn the user stopped). Only when the platform already allows notifications (never asks), one per event (`tag` `ds-bot-approval-` / `ds-bot-turn-`), nothing from before the window opened; a click focuses the window and opens the chat or group, and a notification never approves, rejects or sends. Settings → General → **Notifications** (`prefs.notifications`, on unless `false`) switches every kind off; events seen while off stay told. Quiet hours, mute and *waiting too long* are later slices. Covered by `core/test/approval-notices.test.js`. +- `approval-notices.js` `noticeApprovals` raises one system notification per event while the window is in the background or shows another chat: an approval that starts waiting (`{name} is waiting for your approval`), and a turn of one of the user's own Bots that finished (`{name} finished`) or failed (`{name} failed: `), from `work.ended` (`turns.js` `endedView`: the last 20, process-local; no friend's Bot, no turn a friend started, no hidden turn another Bot asked for, no face-to-face or inbound-share room, no turn the user stopped). Only when the platform already allows notifications (never asks), once per event, nothing from before the window opened; a click focuses the window and opens the chat or group, and a notification never approves, rejects or sends. Settings → General → **Notifications** (`prefs.notifications`, on unless `false`) switches every kind off; events seen while off stay told. +- *Waiting too long* (#469): an approval still waiting `WAITING_TOO_LONG` (15 min) after it started, or after the window opened if it started earlier, gets one more notification (`{name} has waited {n} min for your approval`), from a timer set for the next due approval and cleared with the effect. +- Per-kind switches (#469): under the main switch, **Waiting for approval** (covers the reminder), **Finished** and **Failed** (`prefs.notify.`, on unless `false`). +- One per chat (#469): every notification of a chat or group shares the `tag` `ds-bot-`, so a newer one replaces the older; its body counts the updates (`{count} updates`, the reminder not counted) since the chat was last on screen or its notification clicked. Quiet hours, mute and an in-app list are later slices. Covered by `core/test/approval-notices.test.js`. ### Bot tree chevron diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 977d20d2..3f38738e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -149,7 +149,7 @@ credentials and team mode remain separate work under #55/#198. ### Ended turns for notifications (#353) -- `turns.js` keeps `endedView()` (`trimEnded`): the last 20 turns, plus, past those, each place's (Bot + group) latest turn while it is a failure younger than 7 days (at most 50, for the Agent inbox's failed turns, #471), of the user's own Bots that finished (`reason.kind === 'completed'`) or failed (`failed`: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in `turnFailure`), as `{ id: '::', botId, sessionId | roomId, at }`. Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (`bot.remote`); a turn a friend started (`fromRemote`, a remote-chat line); a hidden turn another Bot asked for (`exchangeTurns`); a group turn in a room with an `origin` (face-to-face, inbound-share). It rides `live().work.ended`; `rt.ownedLive` keeps the actor's own Bots' entries. `set-prefs` takes `notifications: false` (kept) or `true` / `null` (cleared: on by default). The client's use is in [core-client](core-client.md#system-notifications-353). +- `turns.js` keeps `endedView()` (`trimEnded`): the last 20 turns, plus, past those, each place's (Bot + group) latest turn while it is a failure younger than 7 days (at most 50, for the Agent inbox's failed turns, #471), of the user's own Bots that finished (`reason.kind === 'completed'`) or failed (`failed`: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in `turnFailure`), as `{ id: '::', botId, sessionId | roomId, at }`. Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (`bot.remote`); a turn a friend started (`fromRemote`, a remote-chat line); a hidden turn another Bot asked for (`exchangeTurns`); a group turn in a room with an `origin` (face-to-face, inbound-share). It rides `live().work.ended`; `rt.ownedLive` keeps the actor's own Bots' entries. `set-prefs` takes `notifications: false` (kept) or `true` / `null` (cleared: on by default), and `notify: { approval | finished | failed: boolean }` (#469: merged into `prefs.notify`, only the `false` ones kept, removed when empty). The client's use is in [core-client](core-client.md#system-notifications-353). ### Diagnostics diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7a3d3593..197d8467 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -34,32 +34,46 @@ diff --git a/README.md b/README.md diff --git a/src/client/approval-notices.js b/src/client/approval-notices.js --- a/src/client/approval-notices.js +++ b/src/client/approval-notices.js -@@ -1,28 +1,44 @@ +@@ -1,36 +1,82 @@ import { t } from './i18n.js' -// A system notification for each approval that starts waiting while this window is in -// the background or shows another conversation. Only where the platform already allows -// notifications: DS Bot never asks for the permission. -+// A system notification for each approval that starts waiting, and each turn of the user's -+// own Bots that finishes or fails (#353), while this window is in the background or shows -+// another conversation. Only where the platform already allows notifications: DS Bot never -+// asks for the permission. Settings → General → Notifications turns them all off. A -+// notification only opens its chat: it never approves, rejects or sends anything. - export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now() }) { +-export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now() }) { ++// An approval still waiting after this long gets one more reminder (#469, owner decision). ++export const WAITING_TOO_LONG = 15 * 60 * 1000 ++ ++// A system notification for each approval that starts waiting, one more when it has waited ++// too long (#469), and each turn of the user's own Bots that finishes or fails (#353), while ++// this window is in the background or shows another conversation. Only where the platform ++// already allows notifications: DS Bot never asks for the permission. Settings → General → ++// Notifications turns them all off, or each kind on its own (`prefs.notify`, #469). One chat ++// keeps one notification: a newer one replaces it and counts the updates since the chat was ++// last open. A notification only opens its chat: it never approves, rejects or sends anything. ++export function noticeApprovals({ work, roster, shownSession, openSession, since = Date.now(), now = () => Date.now(), timers = globalThis }) { const told = new Set() ++ const unseen = new Map() ++ let timer const notify = () => { - const approvals = work.getSnapshot().approvals ?? [] ++ timers.clearTimeout?.(timer) ++ timer = undefined + const { approvals = [], ended = [] } = work.getSnapshot() ++ const at = now() ++ const lateAt = entry => Math.max(entry.at ?? 0, since) + WAITING_TOO_LONG + const events = [ -+ ...approvals.map(entry => ({ entry, kind: 'approval' })), -+ ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), ++ ...approvals.map(entry => ({ entry, kind: 'approval', key: `approval-${entry.id}`, when: entry.at ?? 0 })), ++ ...approvals.filter(entry => lateAt(entry) <= at).map(entry => ({ entry, kind: 'late', key: `late-${entry.id}`, when: lateAt(entry) })), ++ ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed', key: `turn-${entry.id}`, when: entry.at ?? 0 })), + ] -+ const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` -+ const current = new Set(events.map(key)) ++ const current = new Set(events.map(event => event.key)) for (const id of told) { - if (!approvals.some(entry => entry.id === id)) told.delete(id) + if (!current.has(id)) told.delete(id) } ++ const next = Math.min(...approvals.map(lateAt).filter(due => due > at)) ++ if (Number.isFinite(next)) timer = timers.setTimeout?.(notify, next - at) if (typeof Notification !== 'function' || Notification.permission !== 'granted') return - for (const entry of approvals) { - if (told.has(entry.id)) continue @@ -68,31 +82,58 @@ diff --git a/src/client/approval-notices.js b/src/client/approval-notices.js - if ((entry.at ?? 0) < since) continue + const { byId, roomsById, prefs } = roster.getSnapshot() + const on = prefs?.notifications !== false ++ const kinds = prefs?.notify ?? {} ++ const visible = document.visibilityState === 'visible' ++ const shown = shownSession() ++ if (visible) unseen.delete(shown) ++ const fresh = new Map() + for (const event of events) { -+ const id = key(event) -+ if (told.has(id)) continue -+ told.add(id) ++ if (told.has(event.key)) continue ++ told.add(event.key) + const { entry, kind } = event + // Waiting or ending before this window opened is no news: the sidebar shows it. + // Switched off, it stays told, so switching back on brings no old ones. -+ if (!on || (entry.at ?? 0) < since) continue ++ if (!on || kinds[kind === 'late' ? 'approval' : kind] === false) continue ++ if (kind !== 'late' && (entry.at ?? 0) < since) continue const place = entry.roomId ?? entry.sessionId - if (document.visibilityState === 'visible' && shownSession() === place) continue +- if (document.visibilityState === 'visible' && shownSession() === place) continue - const { byId, roomsById } = roster.getSnapshot() ++ if (visible && shown === place) continue ++ fresh.set(place, [...fresh.get(place) ?? [], event]) ++ } ++ for (const [place, list] of fresh) { ++ const { entry, kind } = list.reduce((last, event) => (event.when >= last.when ? event : last)) ++ const count = (unseen.get(place) ?? 0) + list.filter(event => event.kind !== 'late').length ++ unseen.set(place, count) const room = entry.roomId ? roomsById[entry.roomId] : undefined - const note = new Notification(t('{name} is waiting for your approval', { name: byId[entry.botId]?.name ?? t('A Bot') }), { - body: [room?.name, entry.tool].filter(Boolean).join(' · '), - tag: `ds-bot-approval-${entry.id}`, + const name = byId[entry.botId]?.name ?? t('A Bot') + const title = kind === 'approval' ? t('{name} is waiting for your approval', { name }) -+ : kind === 'finished' ? t('{name} finished', { name }) -+ : t('{name} failed: {reason}', { name, reason: entry.failed }) ++ : kind === 'late' ? t('{name} has waited {n} min for your approval', { name, n: Math.max(15, Math.floor((at - (entry.at ?? at)) / 60000)) }) ++ : kind === 'finished' ? t('{name} finished', { name }) ++ : t('{name} failed: {reason}', { name, reason: entry.failed }) + const note = new Notification(title, { -+ body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), -+ tag: `ds-bot-${id}`, ++ body: [room?.name, kind === 'approval' || kind === 'late' ? entry.tool : undefined, count > 1 ? t('{count} updates', { count }) : undefined].filter(Boolean).join(' · '), ++ tag: `ds-bot-${place}`, }) note.onclick = () => { window.focus() ++ unseen.delete(place) + openSession(entry.roomId ?? entry.botId) + note.close() + } + } + } + notify() +- return work.subscribe(notify) ++ const off = work.subscribe(notify) ++ return () => { ++ off() ++ timers.clearTimeout?.(timer) ++ } + } diff --git a/src/client/bot-browsers.js b/src/client/bot-browsers.js --- a/src/client/bot-browsers.js +++ b/src/client/bot-browsers.js @@ -748,12 +789,15 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -450,9 +450,16 @@ const ZH = { +@@ -450,9 +450,19 @@ const ZH = { 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', + '{name} finished': '{name} 已完成', + '{name} failed: {reason}': '{name} 失败:{reason}', ++ '{name} has waited {n} min for your approval': '{name} 已经等你批准 {n} 分钟了', ++ '{count} updates': '{count} 条更新', ++ 'Once more if it still waits after 15 min.': '等了 15 分钟还没批准时再提醒一次。', + Notifications: '通知', + 'When your Bots finish, fail, or wait for your approval while you are in another chat or app.': '你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。', '{first} and {second} are waiting for your approval': '{first}、{second} 等你批准', @@ -3258,7 +3302,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js const shown = [] const opened = [] function Notification(title, options) { -@@ -26,7 +26,7 @@ function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { +@@ -26,14 +26,16 @@ function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) }, set: (next) => { snapshot = next; for (const listener of listeners) listener() }, } @@ -3267,7 +3311,26 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, Notification, -@@ -62,3 +62,114 @@ test('no notification for the conversation on screen, for approvals older than t + document: { visibilityState: visibility }, + window: { focus() {} }, + }) +- return { ...module.exports, shown, opened, work, roster, openSession: id => opened.push(id) } ++ // Toy timestamps: the clock stays at 1000 unless a test brings its own. ++ const noticeApprovals = options => module.exports.noticeApprovals({ now: () => 1000, ...options }) ++ return { ...module.exports, noticeApprovals, shown, opened, work, roster, openSession: id => opened.push(id) } + } + + test('an approval that starts waiting while another conversation shows gets one notification', () => { +@@ -41,7 +41,7 @@ test('an approval that starts waiting while another conversation shows gets one + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) + page.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 200 }] }) + page.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 200 }] }) +- assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer is waiting for your approval', 'Ops · pwsh', 'ds-bot-approval-x1']]) ++ assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer is waiting for your approval', 'Ops · pwsh', 'ds-bot-r1']]) + page.shown[0].onclick() + assert.deepEqual([page.opened, page.shown[0].closed], [['r1'], true]) + }) +@@ -62,3 +62,207 @@ test('no notification for the conversation on screen, for approvals older than t denied.work.set({ bots: {}, approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 }] }) assert.equal(denied.shown.length, 0) }) @@ -3283,8 +3346,8 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + page.work.set(ended([done, broke])) + page.work.set(ended([done, broke])) + assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [ -+ ['Writer finished', '', 'ds-bot-turn-chat-b:1:200'], -+ ['Writer failed: upstream busy', 'Ops', 'ds-bot-turn-g:2:300'], ++ ['Writer finished', '', 'ds-bot-chat-b'], ++ ['Writer failed: upstream busy', 'Ops', 'ds-bot-r1'], + ]) + page.shown[0].onclick() + page.shown[1].onclick() @@ -3318,7 +3381,93 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + assert.equal(page.shown.length, 0) + page.roster.prefs = {} + page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'chat-b', at: 300 }] }) -+ assert.deepEqual(page.shown.map(note => note.tag), ['ds-bot-turn-e2']) ++ assert.deepEqual(page.shown.map(note => [note.title, note.body]), [['Writer finished', '']]) ++}) ++ ++// A clock and timers the test moves by hand. ++function clock(start) { ++ let now = start ++ const pending = new Map() ++ let seq = 0 ++ return { ++ now: () => now, ++ timers: { ++ setTimeout: (fn, ms) => { pending.set(++seq, { fn, at: now + ms }); return seq }, ++ clearTimeout: id => pending.delete(id), ++ }, ++ pending, ++ advance(ms) { ++ now += ms ++ for (const [id, { fn, at }] of [...pending]) { ++ if (at <= now) { pending.delete(id); fn() } ++ } ++ }, ++ } ++} ++ ++const MIN = 60 * 1000 ++ ++test('an approval still waiting after 15 min gets one more notification; one already decided gets none (#469)', () => { ++ const page = windowWith({ visibility: 'hidden' }) ++ const time = clock(1000) ++ const stop = page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100, now: time.now, timers: time.timers }) ++ const waiting = { id: 'x1', botId: 'b', roomId: 'r1', sessionId: 'gb', tool: 'pwsh', at: 1000 } ++ const decided = { id: 'x2', botId: 'b', sessionId: 'b', tool: 'write', at: 1000 } ++ page.work.set({ bots: {}, approvals: [waiting, decided] }) ++ assert.equal(page.shown.length, 2) ++ page.work.set({ bots: {}, approvals: [waiting] }) ++ time.advance(14 * MIN) ++ assert.equal(page.shown.length, 2) ++ time.advance(MIN) ++ assert.deepEqual(page.shown.slice(2).map(note => [note.title, note.body, note.tag]), [['Writer has waited 15 min for your approval', 'Ops · pwsh', 'ds-bot-r1']]) ++ time.advance(60 * MIN) ++ page.work.set({ bots: {}, approvals: [waiting] }) ++ assert.equal(page.shown.length, 3) ++ stop() ++ assert.equal(time.pending.size, 0) ++}) ++ ++test('an approval that waited since before the window opened is reminded 15 min after it opened', () => { ++ const page = windowWith({ visibility: 'hidden' }) ++ const time = clock(50 * MIN) ++ const old = { id: 'x1', botId: 'b', sessionId: 'b', tool: 'pwsh', at: MIN } ++ page.work.set({ bots: {}, approvals: [old] }) ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 50 * MIN, now: time.now, timers: time.timers }) ++ assert.equal(page.shown.length, 0) ++ time.advance(15 * MIN) ++ assert.deepEqual(page.shown.map(note => note.title), ['Writer has waited 64 min for your approval']) ++}) ++ ++test('each kind has its own switch; off for one kind keeps the others (#469)', () => { ++ const page = windowWith({ visibility: 'hidden', prefs: { notify: { approval: false, finished: false } } }) ++ const time = clock(1000) ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100, now: time.now, timers: time.timers }) ++ page.work.set({ ++ bots: {}, ++ approvals: [{ id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 1000 }], ++ ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 1000 }, { id: 'e2', botId: 'b', sessionId: 'chat-c', at: 1000, failed: 'no model' }], ++ }) ++ time.advance(20 * MIN) ++ assert.deepEqual(page.shown.map(note => note.title), ['Writer failed: no model']) ++}) ++ ++test('notifications from one chat collapse into one that counts the updates until the chat is opened (#469)', () => { ++ let onScreen = 'a' ++ const page = windowWith() ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => onScreen, openSession: page.openSession, since: 100 }) ++ const turn = (id, at, extra) => ({ id, botId: 'b', roomId: 'r1', at, ...extra }) ++ page.work.set(ended([turn('e1', 200), turn('e2', 300, { failed: 'busy' })])) ++ assert.deepEqual(page.shown.map(note => [note.title, note.body, note.tag]), [['Writer failed: busy', 'Ops · 2 updates', 'ds-bot-r1']]) ++ page.work.set(ended([turn('e1', 200), turn('e2', 300, { failed: 'busy' }), turn('e3', 400)])) ++ assert.deepEqual(page.shown.slice(1).map(note => [note.title, note.body, note.tag]), [['Writer finished', 'Ops · 3 updates', 'ds-bot-r1']]) ++ onScreen = 'r1' ++ page.work.set(ended([turn('e3', 400)])) ++ onScreen = 'a' ++ page.work.set(ended([turn('e3', 400), turn('e4', 500)])) ++ assert.deepEqual(page.shown.slice(2).map(note => [note.title, note.body]), [['Writer finished', 'Ops']]) ++ page.shown[2].onclick() ++ page.work.set(ended([turn('e4', 500), turn('e5', 600)])) ++ assert.deepEqual(page.shown.slice(3).map(note => note.body), ['Ops']) +}) + +// The host side: which ended turns the window hears of. @@ -3382,6 +3531,13 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + assert.equal(rt.endedView().length, 20) + assert.ok(rt.endedView().every(entry => entry.failed === undefined)) +}) ++ ++test('Settings → General has one switch per kind under the main one, off while the main one is off (#469)', async () => { ++ const { readFileSync } = await import('node:fs') ++ const settings = readFileSync(new URL('../src/client/settings.js', import.meta.url), 'utf8') ++ assert.match(settings, /\['approval', 'Waiting for approval', 'Once more if it still waits after 15 min\.'\],\n {2}\['finished', 'Finished'\],\n {2}\['failed', 'Failed'\],/) ++ assert.match(settings, /disabled: prefs\.notifications === false,\n\s*on: prefs\.notify\?\.\[kind\] !== false, onChange: on => run\(actions\.setPrefs\(\{ notify: \{ \[kind\]: on \} \}\)\)/) ++}) diff --git a/test/client-bundle.test.js b/test/client-bundle.test.js --- a/test/client-bundle.test.js +++ b/test/client-bundle.test.js @@ -4107,7 +4263,7 @@ diff --git a/test/install.test.js b/test/install.test.js 'effect ds-bot: api route', 'route POST /api/bot', 'effect ds-bot: load state', -@@ -137,6 +137,10 @@ test('the client channel answers from the saved state', async (t) => { +@@ -137,6 +137,17 @@ test('the client channel answers from the saved state', async (t) => { assert.equal((await call('set-prefs', { motion: 'lively' })).value.motion, 'lively') assert.equal('motion' in (await call('set-prefs', { motion: 'normal' })).value, false) assert.deepEqual((await call('set-prefs', { motion: 'wild' })).error, { code: 'bot/invalid', message: 'Motion must be quiet, normal or lively', details: {} }) @@ -4115,6 +4271,13 @@ diff --git a/test/install.test.js b/test/install.test.js + assert.equal((await call('set-prefs', { notifications: false })).value.notifications, false) + assert.equal('notifications' in (await call('set-prefs', { notifications: true })).value, false) + assert.deepEqual((await call('set-prefs', { notifications: 'loud' })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) ++ // Each kind on its own; only the off ones are kept (#469). ++ assert.deepEqual((await call('set-prefs', { notify: { finished: false } })).value.notify, { finished: false }) ++ assert.deepEqual((await call('set-prefs', { notify: { approval: false } })).value.notify, { finished: false, approval: false }) ++ assert.deepEqual((await call('set-prefs', { notify: { finished: true, approval: true } })).value.notify, undefined) ++ for (const notify of [{ loud: false }, { failed: 'no' }, ['approval'], null]) { ++ assert.deepEqual((await call('set-prefs', { notify })).error, { code: 'bot/invalid', message: 'Notifications must be on or off', details: {} }) ++ } // A refusal from the team code keeps its message for the user. assert.deepEqual((await call('delete-bot', { id: 'nobody' })).error, { code: 'bot/refused', message: 'Unknown Bot', details: {} }) // Off the Desktop profile there are no Bot browsers. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 0572ad39..9375a220 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -221,7 +221,7 @@ diff --git a/src/client/mark.js b/src/client/mark.js diff --git a/src/host/channel.js b/src/host/channel.js --- a/src/host/channel.js +++ b/src/host/channel.js -@@ -3,6 +3,9 @@ import { randomUUID } from 'node:crypto' +@@ -3,7 +3,11 @@ import { randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { questionReply } from './parts.js' import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' @@ -229,8 +229,10 @@ diff --git a/src/host/channel.js b/src/host/channel.js +import { faceMentions } from './face-mentions.js' +import { unwrapUntrusted } from './delivery.js' ++const NOTIFY_KINDS = ['approval', 'finished', 'failed'] const LANGUAGES = { zh: 'Chinese (简体中文)', en: 'English' } + const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -21,14 +21,21 @@ export function install(rt) { // The DSH interface language of the last browser that asked for the team. @@ -385,7 +387,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -97,28 +97,44 @@ export function install(rt) { +@@ -97,28 +97,57 @@ export function install(rt) { else if (body.motion === 'quiet' || body.motion === 'lively') state.prefs.motion = body.motion else throw invalid('Motion must be quiet, normal or lively') } @@ -394,6 +396,19 @@ diff --git a/src/host/channel.js b/src/host/channel.js + if (body.notifications === null || body.notifications === true) delete state.prefs.notifications + else if (body.notifications === false) state.prefs.notifications = false + else throw invalid('Notifications must be on or off') ++ } ++ // Each kind is on unless switched off on its own; only the off ones are kept (#469). ++ if (body.notify !== undefined) { ++ const kinds = body.notify ++ if (kinds === null || typeof kinds !== 'object' || Array.isArray(kinds)) throw invalid('Notifications must be on or off') ++ const notify = { ...state.prefs.notify } ++ for (const [kind, on] of Object.entries(kinds)) { ++ if (!NOTIFY_KINDS.includes(kind) || typeof on !== 'boolean') throw invalid('Notifications must be on or off') ++ if (on) delete notify[kind] ++ else notify[kind] = false ++ } ++ if (Object.keys(notify).length > 0) state.prefs.notify = notify ++ else delete state.prefs.notify + } return rt.save() } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 6e9fadc2..d4d4acd2 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -388,12 +388,15 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -506,9 +506,16 @@ var ZH = { +@@ -506,9 +506,19 @@ var ZH = { "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", + "{name} finished": "{name} 已完成", + "{name} failed: {reason}": "{name} 失败:{reason}", ++ "{name} has waited {n} min for your approval": "{name} 已经等你批准 {n} 分钟了", ++ "{count} updates": "{count} 条更新", ++ "Once more if it still waits after 15 min.": "等了 15 分钟还没批准时再提醒一次。", + Notifications: "通知", + "When your Bots finish, fail, or wait for your approval while you are in another chat or app.": "你的 Bot 完成、失败或等你批准时提醒你(你在别的对话或应用里时)。", "{first} and {second} are waiting for your approval": "{first}、{second} 等你批准", @@ -2409,10 +2412,12 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2848,233 +2848,215 @@ function installCompiledStyles(text, tag) { +@@ -2846,235 +2846,217 @@ function installCompiledStyles(text, tag) { + }; + const observer = new MutationObserver(schedule); observer.observe(document.head, { childList: true }); - document.addEventListener("load", onLoad, true); - return () => { +- document.addEventListener("load", onLoad, true); +- return () => { - observer.disconnect(); - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); @@ -2520,6 +2525,8 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; ++ document.addEventListener("load", onLoad, true); ++ return () => { + observer.disconnect(); + document.removeEventListener("load", onLoad, true); + clearTimeout(timer); @@ -3947,8 +3954,9 @@ diff --git a/client.js b/client.js + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-group-msg" }, + "div", + { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + name, + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); @@ -4013,11 +4021,14 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + name, + (0, import_react8.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react8.createElement)(GlobeMark), handle) -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null + ); @@ -4057,15 +4068,7 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react8.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ "div", + { className: "bt-file-chips" }, + files.map((file) => (0, import_react8.createElement)( + "button", @@ -4465,8 +4468,7 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -4479,7 +4481,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { @@ -11117,8 +11120,9 @@ diff --git a/client.js b/client.js } }, t("Cancel")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) -+ ) : null, + ) + ) : null, +- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + on && gateway && picking ? (0, import_react30.createElement)( + "div", + { className: "bt-connector-form" }, @@ -11133,9 +11137,8 @@ diff --git a/client.js b/client.js + setError(null); + } }, t("Cancel")), + (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) - ) - ) : null, -- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ ) ++ ) : null, + error ? (0, import_react30.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } @@ -11491,7 +11494,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10569,54 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s `; // src/client/settings.js @@ -11516,6 +11519,11 @@ diff --git a/client.js b/client.js + children ? (0, import_react33.createElement)("div", { className: "bt-set-control" }, children) : null ); } ++var NOTIFY_KINDS = [ ++ ["approval", "Waiting for approval", "Once more if it still waits after 15 min."], ++ ["finished", "Finished"], ++ ["failed", "Failed"] ++]; function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {}; const themes = allThemes(); @@ -11563,7 +11571,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,79 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,87 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -11578,7 +11586,7 @@ diff --git a/client.js b/client.js ) ), - (0, import_react28.createElement)( -+ // One switch for every system notification: approvals waiting, Bots finished or failed (#353). ++ // One switch for every system notification (#353), then one per kind (#469). + (0, import_react33.createElement)( SetSection, - { key: "mode", title: t("Mode") }, @@ -11594,7 +11602,15 @@ diff --git a/client.js b/client.js + hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."), + on: prefs.notifications !== false, + onChange: (on) => run(actions.setPrefs({ notifications: on })) -+ }) ++ }), ++ NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react33.createElement)(SwitchRow, { ++ key: kind, ++ label: t(label), ++ hint: hint ? t(hint) : void 0, ++ disabled: prefs.notifications === false, ++ on: prefs.notify?.[kind] !== false, ++ onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } })) ++ })) ) ]; } @@ -13511,22 +13527,35 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13219,22 +13219,33 @@ var USER_MESSAGE_CSS = ` - function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { +@@ -13216,42 +13216,112 @@ var USER_MESSAGE_CSS = ` + `; + + // src/client/approval-notices.js +-function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { ++var WAITING_TOO_LONG = 15 * 60 * 1e3; ++function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now(), now = () => Date.now(), timers = globalThis }) { const told = /* @__PURE__ */ new Set(); ++ const unseen = /* @__PURE__ */ new Map(); ++ let timer; const notify = () => { - const approvals = work.getSnapshot().approvals ?? []; ++ timers.clearTimeout?.(timer); ++ timer = void 0; + const { approvals = [], ended = [] } = work.getSnapshot(); ++ const at = now(); ++ const lateAt = (entry) => Math.max(entry.at ?? 0, since2) + WAITING_TOO_LONG; + const events = [ -+ ...approvals.map((entry) => ({ entry, kind: "approval" })), -+ ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) ++ ...approvals.map((entry) => ({ entry, kind: "approval", key: `approval-${entry.id}`, when: entry.at ?? 0 })), ++ ...approvals.filter((entry) => lateAt(entry) <= at).map((entry) => ({ entry, kind: "late", key: `late-${entry.id}`, when: lateAt(entry) })), ++ ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed", key: `turn-${entry.id}`, when: entry.at ?? 0 })) + ]; -+ const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; -+ const current = new Set(events.map(key)); ++ const current = new Set(events.map((event) => event.key)); for (const id of told) { - if (!approvals.some((entry) => entry.id === id)) told.delete(id); + if (!current.has(id)) told.delete(id); } ++ const next = Math.min(...approvals.map(lateAt).filter((due) => due > at)); ++ if (Number.isFinite(next)) timer = timers.setTimeout?.(notify, next - at); if (typeof Notification !== "function" || Notification.permission !== "granted") return; - for (const entry of approvals) { - if (told.has(entry.id)) continue; @@ -13534,31 +13563,54 @@ diff --git a/client.js b/client.js - if ((entry.at ?? 0) < since2) continue; + const { byId, roomsById, prefs } = roster.getSnapshot(); + const on = prefs?.notifications !== false; ++ const kinds = prefs?.notify ?? {}; ++ const visible = document.visibilityState === "visible"; ++ const shown = shownSession(); ++ if (visible) unseen.delete(shown); ++ const fresh = /* @__PURE__ */ new Map(); + for (const event of events) { -+ const id = key(event); -+ if (told.has(id)) continue; -+ told.add(id); ++ if (told.has(event.key)) continue; ++ told.add(event.key); + const { entry, kind } = event; -+ if (!on || (entry.at ?? 0) < since2) continue; ++ if (!on || kinds[kind === "late" ? "approval" : kind] === false) continue; ++ if (kind !== "late" && (entry.at ?? 0) < since2) continue; const place = entry.roomId ?? entry.sessionId; - if (document.visibilityState === "visible" && shownSession() === place) continue; +- if (document.visibilityState === "visible" && shownSession() === place) continue; - const { byId, roomsById } = roster.getSnapshot(); ++ if (visible && shown === place) continue; ++ fresh.set(place, [...fresh.get(place) ?? [], event]); ++ } ++ for (const [place, list] of fresh) { ++ const { entry, kind } = list.reduce((last, event) => event.when >= last.when ? event : last); ++ const count = (unseen.get(place) ?? 0) + list.filter((event) => event.kind !== "late").length; ++ unseen.set(place, count); const room2 = entry.roomId ? roomsById[entry.roomId] : void 0; - const note = new Notification(t("{name} is waiting for your approval", { name: byId[entry.botId]?.name ?? t("A Bot") }), { - body: [room2?.name, entry.tool].filter(Boolean).join(" · "), - tag: `ds-bot-approval-${entry.id}` + const name = byId[entry.botId]?.name ?? t("A Bot"); -+ const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); ++ const title = kind === "approval" ? t("{name} is waiting for your approval", { name }) : kind === "late" ? t("{name} has waited {n} min for your approval", { name, n: Math.max(15, Math.floor((at - (entry.at ?? at)) / 6e4)) }) : kind === "finished" ? t("{name} finished", { name }) : t("{name} failed: {reason}", { name, reason: entry.failed }); + const note = new Notification(title, { -+ body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), -+ tag: `ds-bot-${id}` ++ body: [room2?.name, kind === "approval" || kind === "late" ? entry.tool : void 0, count > 1 ? t("{count} updates", { count }) : void 0].filter(Boolean).join(" · "), ++ tag: `ds-bot-${place}` }); note.onclick = () => { window.focus(); -@@ -13247,11 +13247,45 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since - return work.subscribe(notify); - } - ++ unseen.delete(place); + openSession(entry.roomId ?? entry.botId); + note.close(); + }; + } + }; + notify(); +- return work.subscribe(notify); ++ const off = work.subscribe(notify); ++ return () => { ++ off(); ++ timers.clearTimeout?.(timer); ++ }; ++} ++ +// src/client/stats-dock.js +var import_react41 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; @@ -13581,8 +13633,8 @@ diff --git a/client.js b/client.js + return (0, import_react41.createElement)(entry.component, { ...props, t: t2 }); + } + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; -+} -+ + } + // src/client/index.js var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 59449b2a..1befc373 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -199,28 +199,38 @@ diff --git a/src/client/settings.js b/src/client/settings.js const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { -@@ -35,6 +35,7 @@ function SetRow({ label, hint, children }) { +@@ -35,6 +35,13 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } ++// The notification kinds with a switch of their own (#469). ++const NOTIFY_KINDS = [ ++ ['approval', 'Waiting for approval', 'Once more if it still waits after 15 min.'], ++ ['finished', 'Finished'], ++ ['failed', 'Failed'], ++] +// General is Appearance and Notifications; the switch to classic Agent mode is the account menu's (#393). function GeneralPage({ roster, actions, run }) { const prefs = roster.prefs ?? {} const themes = allThemes() -@@ -61,9 +61,13 @@ function GeneralPage({ roster, actions, run }) { +@@ -61,9 +61,17 @@ function GeneralPage({ roster, actions, run }) { })), h('label', { className: 'bt-swatch bt-swatch-custom', title: t('Custom accent'), 'data-on': Boolean(prefs.accent) && !ACCENT_INKS.some(ink => INKS[ink][0] === prefs.accent) }, h('input', { type: 'color', value: custom, 'aria-label': t('Custom accent'), onChange: event => pickCustom(event.target.value) }))))), - h(SetSection, { key: 'mode', title: t('Mode') }, - h(SetRow, { label: t('Classic Agent mode') }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => actions.setSurface('agent') }, t('Switch')))), -+ // One switch for every system notification: approvals waiting, Bots finished or failed (#353). ++ // One switch for every system notification (#353), then one per kind (#469). + h(SetSection, { key: 'notify', title: t('Notifications') }, + h(SwitchRow, { + label: t('Notifications'), showLabel: false, + hint: t('When your Bots finish, fail, or wait for your approval while you are in another chat or app.'), + on: prefs.notifications !== false, onChange: on => run(actions.setPrefs({ notifications: on })), -+ })), ++ }), ++ NOTIFY_KINDS.map(([kind, label, hint]) => h(SwitchRow, { ++ key: kind, label: t(label), hint: hint ? t(hint) : undefined, disabled: prefs.notifications === false, ++ on: prefs.notify?.[kind] !== false, onChange: on => run(actions.setPrefs({ notify: { [kind]: on } })), ++ }))), ] }