From a52a5d59d97bb8ca3afbebdfe5b4844634c6d926 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:01:32 +0800 Subject: [PATCH 1/2] Notify when my own Bots finish or fail a turn; one Notifications switch (#353) --- .../353-finish-fail-notifications.added.md | 1 + core/client.js | 47 +++- core/src/client/approval-notices.js | 44 ++- core/src/client/i18n.js | 4 + core/src/client/settings.js | 11 +- core/src/host/authz.js | 1 + core/src/host/channel.js | 10 +- core/src/host/turns.js | 28 +- core/test/approval-notices.test.js | 112 +++++++- core/test/authz.test.js | 3 +- core/test/install.test.js | 4 + docs/subsystems/core-client.md | 4 + patches/ds-bot/0001-core-fixes.patch | 259 +++++++++++++++++- patches/ds-bot/0005-social-seams.patch | 16 +- patches/ds-bot/0008-client-bundle.patch | 210 +++++++++----- patches/ds-bot/0014-flybot-branding.patch | 18 +- patches/ds-bot/files/src/host/authz.js | 1 + patches/ds-bot/files/test/authz.test.js | 3 +- patches/ds-bot/topics.json | 4 +- 19 files changed, 661 insertions(+), 119 deletions(-) create mode 100644 changelog.d/353-finish-fail-notifications.added.md diff --git a/changelog.d/353-finish-fail-notifications.added.md b/changelog.d/353-finish-fail-notifications.added.md new file mode 100644 index 00000000..f960040f --- /dev/null +++ b/changelog.d/353-finish-fail-notifications.added.md @@ -0,0 +1 @@ +FlyBot now sends a system notification when one of your own Bots finishes or fails a turn while you are in another chat or app (`Coder finished`, `Coder failed: `), alongside the existing approval-waiting one; clicking it opens that chat, and it never approves, rejects or sends anything. Settings → General has a **Notifications** switch, on by default, that turns all of them off ([#353](https://github.com/ThinkFlowLab/FlyBot/issues/353)). diff --git a/core/client.js b/core/client.js index b61811de..671e6369 100644 --- a/core/client.js +++ b/core/client.js @@ -666,6 +666,10 @@ 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}", + 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} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -13166,6 +13170,18 @@ function GeneralPage({ roster, actions, run }) { ) ) ) + ), + // One switch for every system notification: approvals waiting, Bots finished or failed (#353). + (0, import_react32.createElement)( + SetSection, + { key: "notify", title: t("Notifications") }, + (0, import_react32.createElement)(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 })) + }) ) ]; } @@ -16024,22 +16040,33 @@ var USER_MESSAGE_CSS = ` function noticeApprovals({ work, roster, shownSession, openSession, since: since2 = Date.now() }) { const told = /* @__PURE__ */ new Set(); const notify = () => { - const approvals = work.getSnapshot().approvals ?? []; + const { approvals = [], ended = [] } = work.getSnapshot(); + const events = [ + ...approvals.map((entry) => ({ entry, kind: "approval" })), + ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) + ]; + const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; + const current = new Set(events.map(key)); for (const id of told) { - if (!approvals.some((entry) => entry.id === id)) told.delete(id); + if (!current.has(id)) told.delete(id); } if (typeof Notification !== "function" || Notification.permission !== "granted") return; - for (const entry of approvals) { - if (told.has(entry.id)) continue; - told.add(entry.id); - if ((entry.at ?? 0) < since2) continue; + const { byId, roomsById, prefs } = roster.getSnapshot(); + const on = prefs?.notifications !== false; + for (const event of events) { + const id = key(event); + if (told.has(id)) continue; + told.add(id); + const { entry, kind } = event; + if (!on || (entry.at ?? 0) < since2) continue; const place = entry.roomId ?? entry.sessionId; if (document.visibilityState === "visible" && shownSession() === place) continue; - const { byId, roomsById } = roster.getSnapshot(); 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 note = new Notification(title, { + body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), + tag: `ds-bot-${id}` }); note.onclick = () => { window.focus(); diff --git a/core/src/client/approval-notices.js b/core/src/client/approval-notices.js index 4b863698..a1754d36 100644 --- a/core/src/client/approval-notices.js +++ b/core/src/client/approval-notices.js @@ -1,28 +1,44 @@ 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() }) { const told = new Set() const notify = () => { - const approvals = work.getSnapshot().approvals ?? [] + const { approvals = [], ended = [] } = work.getSnapshot() + const events = [ + ...approvals.map(entry => ({ entry, kind: 'approval' })), + ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), + ] + const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` + const current = new Set(events.map(key)) for (const id of told) { - if (!approvals.some(entry => entry.id === id)) told.delete(id) + if (!current.has(id)) told.delete(id) } if (typeof Notification !== 'function' || Notification.permission !== 'granted') return - for (const entry of approvals) { - if (told.has(entry.id)) continue - told.add(entry.id) - // Waiting before this window opened is no news: the sidebar shows it. - if ((entry.at ?? 0) < since) continue + const { byId, roomsById, prefs } = roster.getSnapshot() + const on = prefs?.notifications !== false + for (const event of events) { + const id = key(event) + if (told.has(id)) continue + told.add(id) + 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 const place = entry.roomId ?? entry.sessionId if (document.visibilityState === 'visible' && shownSession() === place) continue - const { byId, roomsById } = roster.getSnapshot() 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 }) + const note = new Notification(title, { + body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), + tag: `ds-bot-${id}`, }) note.onclick = () => { window.focus() diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index e635d3b7..8807c3ca 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -610,6 +610,10 @@ 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}', + 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} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', diff --git a/core/src/client/settings.js b/core/src/client/settings.js index aafb68f5..93bbb4d5 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -7,7 +7,7 @@ import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' import { useEscape } from './overlay-hooks.js' -import { BotSettingsForm } from './bot-settings.js' +import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' import { GroupSettingsForm, NewGroupForm } from './group-settings.js' import { CreateBot } from './create-bot.js' @@ -36,7 +36,7 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } -// General is Appearance only; the switch to classic Agent mode is the account menu's (#393). +// 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() @@ -63,6 +63,13 @@ 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). + 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 })), + })), ] } diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 0784b528..d0948347 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -181,6 +181,7 @@ export function install(rt) { bots: pick(full.work.bots), rooms: Object.fromEntries(Object.entries(full.work.rooms ?? {}).filter(([id]) => roomMemberOf(actor, id) || roomOwnedBy(actor, id))), approvals: full.work.approvals.filter(entry => mine(entry.botId)), + ended: (full.work.ended ?? []).filter(entry => mine(entry.botId)), }, memoryNews: pick(full.memoryNews), memoryReviews: alone() ? full.memoryReviews : 0, diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 725921c1..8437f1ca 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -113,11 +113,11 @@ export function install(rt) { // What changes without a new revision, sent with every state answer. `memoryNews`: // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or // running; `work`: per Bot, the turn it runs and its tool; per group, the members whose - // turn there runs; and the approvals waiting. + // turn there runs; the approvals waiting; and the turns that just finished or failed. const live = actor => { const full = { activity: rt.activityView(), - work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, + work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView(), ended: rt.endedView?.() ?? [] }, memoryNews: rt.memoryNews(), memoryReviews: rt.memoryReviews(), connectors: rt.connectorsView(), @@ -164,6 +164,12 @@ 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') } + // Notifications are on unless switched off (#353). + if (body.notifications !== undefined) { + 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') + } return rt.save() } diff --git a/core/src/host/turns.js b/core/src/host/turns.js index a5bbe7df..6f031bcd 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -253,6 +253,31 @@ export function install(rt) { return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } + // The latest turns of the user's own Bots that finished or failed, for a system notification + // (#353). Process-local and bounded; a window skips the ones from before it opened. Left out: + // a friend's Bots, turns a friend started, turns another Bot asked for (hidden in the chat), + // rooms shared with other people, and turns the user stopped. + const ENDED_KEPT = 20 + let ended = [] + const noteEnded = (id, turn, info, reason, failure) => { + if (failure === undefined && reason?.kind !== 'completed') return + const owner = rt.groupOwners.get(id) + const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) + if (bot === undefined || bot.remote !== undefined) return + if (info !== undefined && (fromRemote(info) || info.role === 'remote' || info.form === 'remote-chat')) return + if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return + if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return + const at = Date.now() + ended = [...ended, { + id: `${id}:${turn}:${at}`, + botId: bot.id, + ...(owner ? { roomId: owner.roomId } : { sessionId: id }), + at, + ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), + }].slice(-ENDED_KEPT) + } + const endedView = () => ended + // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) @@ -460,6 +485,7 @@ export function install(rt) { const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) + if (!retry) noteEnded(id, answeredTurn, info, reason, failure) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply @@ -566,5 +592,5 @@ export function install(rt) { return found } - Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn }) + Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) } diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index c7792aed..99e281e4 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -3,6 +3,8 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { runInNewContext } from 'node:vm' import { build } from 'esbuild' +import { SOURCE_KIND } from '../src/host/constants.js' +import { install as installTurns } from '../src/host/turns.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/approval-notices.js', import.meta.url))], @@ -10,7 +12,7 @@ const bundle = await build({ }) // One window: the platform's Notification, whether the page shows, and the work source. -function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { +function windowWith({ permission = 'granted', visibility = 'visible', prefs = {} } = {}) { const shown = [] const opened = [] function Notification(title, options) { @@ -26,7 +28,7 @@ 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() }, } - const roster = { getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } } }) } + const roster = { prefs, getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } }, prefs: roster.prefs }) } const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, Notification, @@ -62,3 +64,109 @@ 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) }) + +const ended = (entries) => ({ bots: {}, approvals: [], ended: entries }) + +test('a turn of my own Bot that finishes or fails elsewhere gets one notification that says why and opens its chat (#353)', () => { + const page = windowWith() + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) + const done = { id: 'chat-b:1:200', botId: 'b', sessionId: 'chat-b', at: 200 } + const broke = { id: 'g:2:300', botId: 'b', roomId: 'r1', at: 300, failed: 'upstream busy' } + page.work.set(ended([done])) + 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'], + ]) + page.shown[0].onclick() + page.shown[1].onclick() + assert.deepEqual([page.opened, page.shown.map(note => note.closed)], [['b', 'r1'], [true, true]]) + // A notification carries nothing to act on: no buttons, no reply. + assert.equal(page.shown.some(note => 'actions' in note), false) +}) + +test('no turn notification for the chat on screen, for turns that ended before the window opened, or without permission', () => { + const onScreen = windowWith() + onScreen.noticeApprovals({ work: onScreen.work, roster: onScreen.roster, shownSession: () => 'chat-b', openSession: onScreen.openSession, since: 100 }) + onScreen.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'other', at: 50 }])) + assert.equal(onScreen.shown.length, 0) + + const hidden = windowWith({ visibility: 'hidden' }) + hidden.noticeApprovals({ work: hidden.work, roster: hidden.roster, shownSession: () => 'chat-b', openSession: hidden.openSession, since: 100 }) + hidden.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) + assert.equal(hidden.shown.length, 1) + + const denied = windowWith({ permission: 'denied' }) + denied.noticeApprovals({ work: denied.work, roster: denied.roster, shownSession: () => 'a', openSession: denied.openSession, since: 100 }) + denied.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) + assert.equal(denied.shown.length, 0) +}) + +test('the Notifications switch turns off every kind, and switching it back on brings no old ones', () => { + const page = windowWith({ prefs: { notifications: false } }) + page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) + const approval = { id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 } + page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }] }) + 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']) +}) + +// The host side: which ended turns the window hears of. +function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { + let listener + const rt = { + ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, + state: { exchangeTurns: {}, questions: {} }, + isBotSession: () => true, + roomOf: id => rooms[id], + groupOwners, + chatOwners: new Map(), + botOf: id => bots[id], + selfOf: id => groupOwners.get(id)?.botId ?? id, + save: async () => {}, + deliver: async () => {}, + noteFailure() {}, + warn() {}, + switchJobs: () => [], + dropKickoff() {}, + flybotReplyRemote: async () => ({}), + } + installTurns(rt) + let seq = 0 + const emit = (id, type, data) => listener({ id }, { type, seq: ++seq, data }) + const turn = (id, n, reason, source) => { + emit(id, 'turn/start', { turn: n }) + if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) + emit(id, 'turn/end', { turn: n, reason }) + } + return { rt, turn } +} + +const completed = { kind: 'completed' } +const failed = { kind: 'error', error: { code: 'SERVER', message: 'upstream busy', status: 503 } } + +test('the host lists finished and failed turns of my own Bots, not stopped, asked-by-a-Bot, friend or shared-room turns', () => { + const { rt, turn } = turnsHost({ + bots: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } }, + rooms: { r1: { id: 'r1' }, face: { id: 'face', origin: 'face-to-face' } }, + groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }], ['g2', { botId: 'b', roomId: 'face' }]]), + }) + turn('b', 1, completed) + turn('b', 2, failed) + turn('b', 3, { kind: 'aborted', reason: { kind: 'user' } }) + turn('b', 4, completed, { kind: SOURCE_KIND, role: 'request', senderSessionId: 'a', exchangeId: 'x1', hop: 1 }) + turn('b', 5, completed, { kind: SOURCE_KIND, role: 'request', remote: { handle: '@pal' }, exchangeId: 'x2', hop: 1 }) + turn('f', 1, completed) + turn('g1', 1, failed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1x' }) + turn('g2', 1, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r2x' }) + assert.deepEqual(rt.endedView().map(({ id, at, ...entry }) => [id.split(':').slice(0, 2).join(':'), entry]), [ + ['b:1', { botId: 'b', sessionId: 'b' }], + ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], + ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], + ]) + for (let n = 10; n < 40; n++) turn('b', n, completed) + assert.equal(rt.endedView().length, 20) +}) diff --git a/core/test/authz.test.js b/core/test/authz.test.js index aca83a45..9e6cbffe 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -309,7 +309,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as } const fullLive = { activity: { a1: 'busy', b1: 'idle' }, - work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }] }, + work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }], ended: [{ botId: 'a1' }, { botId: 'b1' }] }, memoryNews: { a1: 1, b1: 2 }, memoryReviews: 3, connectors: [{ id: 'c' }], @@ -339,6 +339,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as const live2 = rt.ownedLive(fullLive, solo) assert.deepEqual(Object.keys(live2.activity), ['a1']) assert.deepEqual(live2.work.approvals.map(entry => entry.botId), ['a1']) + assert.deepEqual(live2.work.ended.map(entry => entry.botId), ['a1']) assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) diff --git a/core/test/install.test.js b/core/test/install.test.js index b41d4d54..7c384212 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -144,6 +144,10 @@ 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: {} }) + // Notifications are on unless switched off; only off is kept (#353). + 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: {} }) // 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 63f44841..878bfd2f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -98,6 +98,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A Bot row uses the same gold wait dot as a room row when `bot.pendingCount > 0` (Bot Social holds on that friend's chat) or a local hold is open. Shared-room overview pending lines stay one line with ellipsis (`bt-shared-wait`). +### 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`. + ### Bot tree chevron - The sidebar Bot-list tree (`sidebar.js` `TreeNode`, from slot `flybot.sidebar.structure`; optional `include(botId)` filters the default list, #452) draws collapse/expand as `.bt-tree-toggle`: a 28×28 hit target with a 16px `.bt-tree-chev` glyph, hover background, and a `:focus-visible` accent ring. The glyph rotates when `aria-expanded` is false; the button stays upright so hover and focus stay square. Expand/Collapse `{name}` labels and `aria-expanded` are unchanged. Leaf rows use the same 28px width as a spacer (`.bt-tree-leaf`) so names stay aligned; indent is still `depth * 16px` and the Bot row keeps its height. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 82d0fd10..e607062f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -31,6 +31,68 @@ diff --git a/README.md b/README.md - Give every model a `contextWindow`. DS Bot takes its lines from it: the usable window is the window minus the reply reserve (`maxTokens`), the hard line is 95% of that, and the check line is `checkRatio` of it. A model without `contextWindow` gets the adapter's default (262,144 tokens for `llm-pi-ai`), which may be wrong for that model; a model that reports no window at all counts as 500,000. - To send images, the provider needs `defaultInput: [ text, image ]`. +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 @@ + 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() }) { + const told = new Set() + const notify = () => { +- const approvals = work.getSnapshot().approvals ?? [] ++ const { approvals = [], ended = [] } = work.getSnapshot() ++ const events = [ ++ ...approvals.map(entry => ({ entry, kind: 'approval' })), ++ ...ended.map(entry => ({ entry, kind: entry.failed === undefined ? 'finished' : 'failed' })), ++ ] ++ const key = ({ entry, kind }) => `${kind === 'approval' ? 'approval' : 'turn'}-${entry.id}` ++ const current = new Set(events.map(key)) + for (const id of told) { +- if (!approvals.some(entry => entry.id === id)) told.delete(id) ++ if (!current.has(id)) told.delete(id) + } + if (typeof Notification !== 'function' || Notification.permission !== 'granted') return +- for (const entry of approvals) { +- if (told.has(entry.id)) continue +- told.add(entry.id) +- // Waiting before this window opened is no news: the sidebar shows it. +- if ((entry.at ?? 0) < since) continue ++ const { byId, roomsById, prefs } = roster.getSnapshot() ++ const on = prefs?.notifications !== false ++ for (const event of events) { ++ const id = key(event) ++ if (told.has(id)) continue ++ told.add(id) ++ 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 + const place = entry.roomId ?? entry.sessionId + if (document.visibilityState === 'visible' && shownSession() === place) continue +- const { byId, roomsById } = roster.getSnapshot() + 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 }) ++ const note = new Notification(title, { ++ body: [room?.name, kind === 'approval' ? entry.tool : undefined].filter(Boolean).join(' · '), ++ tag: `ds-bot-${id}`, + }) + note.onclick = () => { + window.focus() 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 @@ -584,7 +646,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -453,6 +453,9 @@ const ZH = { +@@ -450,9 +450,16 @@ 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}', ++ 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} 等你批准', '{first}, {second} and {count} more are waiting for your approval': '{first}、{second} 等 {total} 个等你批准', 'Waiting for your approval': '等你批准', @@ -2187,6 +2256,38 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } +@@ -238,6 +238,31 @@ export function install(rt) { + return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } + } + ++ // The latest turns of the user's own Bots that finished or failed, for a system notification ++ // (#353). Process-local and bounded; a window skips the ones from before it opened. Left out: ++ // a friend's Bots, turns a friend started, turns another Bot asked for (hidden in the chat), ++ // rooms shared with other people, and turns the user stopped. ++ const ENDED_KEPT = 20 ++ let ended = [] ++ const noteEnded = (id, turn, info, reason, failure) => { ++ if (failure === undefined && reason?.kind !== 'completed') return ++ const owner = rt.groupOwners.get(id) ++ const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) ++ if (bot === undefined || bot.remote !== undefined) return ++ if (info !== undefined && (fromRemote(info) || info.role === 'remote' || info.form === 'remote-chat')) return ++ if (owner === undefined && state.exchangeTurns?.[id]?.includes(turn)) return ++ if (owner !== undefined && rt.roomOf(owner.roomId)?.origin !== undefined) return ++ const at = Date.now() ++ ended = [...ended, { ++ id: `${id}:${turn}:${at}`, ++ botId: bot.id, ++ ...(owner ? { roomId: owner.roomId } : { sessionId: id }), ++ at, ++ ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), ++ }].slice(-ENDED_KEPT) ++ } ++ const endedView = () => ended ++ + // Where a Session speaks, for the diagnostics log. + const placeOf = (id) => { + const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) @@ -293,6 +293,10 @@ export function install(rt) { entry.answers = false entry.opened = false @@ -2274,12 +2375,13 @@ diff --git a/src/host/turns.js b/src/host/turns.js const reason = event.data?.reason const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined if (failure !== undefined) noteTurnFailure(id, failure) -@@ -411,7 +411,7 @@ export function install(rt) { +@@ -411,7 +411,8 @@ export function install(rt) { // the call. const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) - if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) + if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) ++ if (!retry) noteEnded(id, answeredTurn, info, reason, failure) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply @@ -2355,7 +2457,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + return found + } + -+ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn }) ++ Object.assign(rt, { botTurnOrigin, live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, endedView, callDetail, stopRoomTurn }) } diff --git a/src/host/usage.js b/src/host/usage.js --- a/src/host/usage.js @@ -2397,6 +2499,146 @@ diff --git a/src/shared/mentions.js b/src/shared/mentions.js + } + return missing +} +diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js +--- a/test/approval-notices.test.js ++++ b/test/approval-notices.test.js +@@ -3,6 +3,8 @@ import { test } from 'node:test' + import { fileURLToPath } from 'node:url' + import { runInNewContext } from 'node:vm' + import { build } from 'esbuild' ++import { SOURCE_KIND } from '../src/host/constants.js' ++import { install as installTurns } from '../src/host/turns.js' + + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/approval-notices.js', import.meta.url))], +@@ -10,7 +10,7 @@ const bundle = await build({ + }) + + // One window: the platform's Notification, whether the page shows, and the work source. +-function windowWith({ permission = 'granted', visibility = 'visible' } = {}) { ++function windowWith({ permission = 'granted', visibility = 'visible', prefs = {} } = {}) { + const shown = [] + const opened = [] + function Notification(title, options) { +@@ -26,7 +26,7 @@ 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() }, + } +- const roster = { getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } } }) } ++ const roster = { prefs, getSnapshot: () => ({ byId: { b: { id: 'b', name: 'Writer' } }, roomsById: { r1: { id: 'r1', name: 'Ops' } }, prefs: roster.prefs }) } + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, Notification, +@@ -62,3 +62,109 @@ 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) + }) ++ ++const ended = (entries) => ({ bots: {}, approvals: [], ended: entries }) ++ ++test('a turn of my own Bot that finishes or fails elsewhere gets one notification that says why and opens its chat (#353)', () => { ++ const page = windowWith() ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) ++ const done = { id: 'chat-b:1:200', botId: 'b', sessionId: 'chat-b', at: 200 } ++ const broke = { id: 'g:2:300', botId: 'b', roomId: 'r1', at: 300, failed: 'upstream busy' } ++ page.work.set(ended([done])) ++ 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'], ++ ]) ++ page.shown[0].onclick() ++ page.shown[1].onclick() ++ assert.deepEqual([page.opened, page.shown.map(note => note.closed)], [['b', 'r1'], [true, true]]) ++ // A notification carries nothing to act on: no buttons, no reply. ++ assert.equal(page.shown.some(note => 'actions' in note), false) ++}) ++ ++test('no turn notification for the chat on screen, for turns that ended before the window opened, or without permission', () => { ++ const onScreen = windowWith() ++ onScreen.noticeApprovals({ work: onScreen.work, roster: onScreen.roster, shownSession: () => 'chat-b', openSession: onScreen.openSession, since: 100 }) ++ onScreen.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }, { id: 'e2', botId: 'b', sessionId: 'other', at: 50 }])) ++ assert.equal(onScreen.shown.length, 0) ++ ++ const hidden = windowWith({ visibility: 'hidden' }) ++ hidden.noticeApprovals({ work: hidden.work, roster: hidden.roster, shownSession: () => 'chat-b', openSession: hidden.openSession, since: 100 }) ++ hidden.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) ++ assert.equal(hidden.shown.length, 1) ++ ++ const denied = windowWith({ permission: 'denied' }) ++ denied.noticeApprovals({ work: denied.work, roster: denied.roster, shownSession: () => 'a', openSession: denied.openSession, since: 100 }) ++ denied.work.set(ended([{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }])) ++ assert.equal(denied.shown.length, 0) ++}) ++ ++test('the Notifications switch turns off every kind, and switching it back on brings no old ones', () => { ++ const page = windowWith({ prefs: { notifications: false } }) ++ page.noticeApprovals({ work: page.work, roster: page.roster, shownSession: () => 'a', openSession: page.openSession, since: 100 }) ++ const approval = { id: 'x1', botId: 'b', roomId: 'r1', tool: 'pwsh', at: 200 } ++ page.work.set({ bots: {}, approvals: [approval], ended: [{ id: 'e1', botId: 'b', sessionId: 'chat-b', at: 200 }] }) ++ 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']) ++}) ++ ++// The host side: which ended turns the window hears of. ++function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { ++ let listener ++ const rt = { ++ ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, ++ state: { exchangeTurns: {}, questions: {} }, ++ isBotSession: () => true, ++ roomOf: id => rooms[id], ++ groupOwners, ++ chatOwners: new Map(), ++ botOf: id => bots[id], ++ selfOf: id => groupOwners.get(id)?.botId ?? id, ++ save: async () => {}, ++ deliver: async () => {}, ++ noteFailure() {}, ++ warn() {}, ++ switchJobs: () => [], ++ dropKickoff() {}, ++ flybotReplyRemote: async () => ({}), ++ } ++ installTurns(rt) ++ let seq = 0 ++ const emit = (id, type, data) => listener({ id }, { type, seq: ++seq, data }) ++ const turn = (id, n, reason, source) => { ++ emit(id, 'turn/start', { turn: n }) ++ if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) ++ emit(id, 'turn/end', { turn: n, reason }) ++ } ++ return { rt, turn } ++} ++ ++const completed = { kind: 'completed' } ++const failed = { kind: 'error', error: { code: 'SERVER', message: 'upstream busy', status: 503 } } ++ ++test('the host lists finished and failed turns of my own Bots, not stopped, asked-by-a-Bot, friend or shared-room turns', () => { ++ const { rt, turn } = turnsHost({ ++ bots: { a: { id: 'a', name: 'Chief' }, b: { id: 'b', name: 'Coder' }, f: { id: 'f', name: 'Pal', remote: { handle: '@pal' } } }, ++ rooms: { r1: { id: 'r1' }, face: { id: 'face', origin: 'face-to-face' } }, ++ groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }], ['g2', { botId: 'b', roomId: 'face' }]]), ++ }) ++ turn('b', 1, completed) ++ turn('b', 2, failed) ++ turn('b', 3, { kind: 'aborted', reason: { kind: 'user' } }) ++ turn('b', 4, completed, { kind: SOURCE_KIND, role: 'request', senderSessionId: 'a', exchangeId: 'x1', hop: 1 }) ++ turn('b', 5, completed, { kind: SOURCE_KIND, role: 'request', remote: { handle: '@pal' }, exchangeId: 'x2', hop: 1 }) ++ turn('f', 1, completed) ++ turn('g1', 1, failed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1x' }) ++ turn('g2', 1, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r2x' }) ++ assert.deepEqual(rt.endedView().map(({ id, at, ...entry }) => [id.split(':').slice(0, 2).join(':'), entry]), [ ++ ['b:1', { botId: 'b', sessionId: 'b' }], ++ ['b:2', { botId: 'b', sessionId: 'b', failed: 'upstream busy' }], ++ ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], ++ ]) ++ for (let n = 10; n < 40; n++) turn('b', n, completed) ++ assert.equal(rt.endedView().length, 20) ++}) 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 @@ -3017,6 +3259,17 @@ 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) => { + 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: {} }) ++ // Notifications are on unless switched off; only off is kept (#353). ++ 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: {} }) + // 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/test/memory.test.js b/test/memory.test.js --- a/test/memory.test.js +++ b/test/memory.test.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 1db79517..d5152a22 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -331,7 +331,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // What changes without a new revision, sent with every state answer. `memoryNews`: // per Bot, its latest memory change; `memoryReviews`: memory reviews waiting or // running; `work`: per Bot, the turn it runs and its tool; per group, the members whose - // turn there runs; and the approvals waiting. +- // turn there runs; and the approvals waiting. - const live = () => ({ - activity: rt.activityView(), - work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, @@ -340,10 +340,11 @@ diff --git a/src/host/channel.js b/src/host/channel.js - connectors: rt.connectorsView(), - capabilityStatuses: rt.capabilityStatusesView(), - }) ++ // turn there runs; the approvals waiting; and the turns that just finished or failed. + const live = actor => { + const full = { + activity: rt.activityView(), -+ work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView() }, ++ work: { bots: rt.workView(), rooms: rt.roomWorkView(), approvals: rt.approvalsView(), ended: rt.endedView?.() ?? [] }, + memoryNews: rt.memoryNews(), + memoryReviews: rt.memoryReviews(), + connectors: rt.connectorsView(), @@ -378,7 +379,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js function setPrefs(body) { if (body.theme !== undefined) { -@@ -100,25 +100,34 @@ export function install(rt) { +@@ -97,28 +97,43 @@ 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') + } ++ // Notifications are on unless switched off (#353). ++ if (body.notifications !== undefined) { ++ 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') ++ } return rt.save() } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9eb02c5a..5aabfd48 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -341,7 +341,14 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -509,6 +509,9 @@ var ZH = { +@@ -506,9 +506,16 @@ 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}", ++ 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} 等你批准", "{first}, {second} and {count} more are waiting for your approval": "{first}、{second} 等 {total} 个等你批准", "Waiting for your approval": "等你批准", @@ -1782,7 +1789,15 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+} + } +-function previewOf(list, id, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- for (let index = outline.length - 1; index >= 0; index -= 1) { +- const entry = outline[index]; +- if (hiddenTurns.includes(entry.turn)) continue; +- const text = entry.response || entry.prompt || ""; +- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -1794,17 +1809,21 @@ diff --git a/client.js b/client.js + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; -+ } + } +- return ""; + return { ...state, [sessionId]: own }; } --function previewOf(list, id, hiddenTurns = []) { -- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; -- if (!Array.isArray(outline)) return ""; -- for (let index = outline.length - 1; index >= 0; index -= 1) { -- const entry = outline[index]; -- if (hiddenTurns.includes(entry.turn)) continue; -- const text = entry.response || entry.prompt || ""; -- if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); +-var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); +-function usageOf(list, ids) { +- let input = 0; +- let cacheRead = 0; +- let output = 0; +- for (const id of ids) { +- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; +- if (!usage) continue; +- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); +- cacheRead += usage.cacheReadTokens ?? 0; +- output += usage.outputTokens ?? 0; + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1829,7 +1848,9 @@ diff --git a/client.js b/client.js + light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, + dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } } -- return ""; +- if (input + output === 0) return ""; +- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; +- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); +}; +var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; +function themeVars(mode, dark, accent = mode.accent) { @@ -1852,18 +1873,7 @@ diff --git a/client.js b/client.js + "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), + "--bt-tag-ink": rgba(mode.text, 0.58) + }; - } --var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); --function usageOf(list, ids) { -- let input = 0; -- let cacheRead = 0; -- let output = 0; -- for (const id of ids) { -- const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; -- if (!usage) continue; -- input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); -- cacheRead += usage.cacheReadTokens ?? 0; -- output += usage.outputTokens ?? 0; ++} +var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); +var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} +html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1911,10 +1921,7 @@ diff --git a/client.js b/client.js + if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); + for (const mode of ["light", "dark"]) { + for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); - } -- if (input + output === 0) return ""; -- const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; -- return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); ++ } + const previous = customThemes[id]; + customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; + registry3.set((value) => value + 1); @@ -2358,20 +2365,6 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +function isGenericFaceTitle(name) { @@ -2403,13 +2396,19 @@ diff --git a/client.js b/client.js + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2419,9 +2418,7 @@ diff --git a/client.js b/client.js + if (!owner || seen.has(key)) continue; + seen.add(key); + out.push(owner); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); +} @@ -2446,18 +2443,13 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2484,11 +2476,25 @@ diff --git a/client.js b/client.js + handle: bot.remote.handle, + isChief: bot.remote.isChief + }); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +function installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -4289,12 +4295,12 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- 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 }); ++ } + 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, { @@ -9162,13 +9168,13 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( + face ? (0, import_react27.createElement)( - Card, -- { title: t("Members · {count}", { count: members.length }) }, ++ Card, + { title: t("Members · {count}", { count: people.length }) }, + people.map((person) => (0, import_react27.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot })) + ) : null, + face ? null : (0, import_react27.createElement)( -+ Card, + Card, +- { title: t("Members · {count}", { count: members.length }) }, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react27.createElement)( + "div", @@ -10688,7 +10694,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,67 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +10626,79 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -10701,15 +10707,25 @@ diff --git a/client.js b/client.js ) ) ) -- ), + ), - (0, import_react28.createElement)( -- SetSection, ++ // One switch for every system notification: approvals waiting, Bots finished or failed (#353). ++ (0, import_react32.createElement)( + SetSection, - { key: "mode", title: t("Mode") }, - (0, import_react28.createElement)( - SetRow, - { label: t("Classic Agent mode") }, - (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.setSurface("agent") }, t("Switch")) - ) ++ { key: "notify", title: t("Notifications") }, ++ (0, import_react32.createElement)(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 })) ++ }) ) ]; } @@ -12485,6 +12501,50 @@ 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() }) { + const told = /* @__PURE__ */ new Set(); + const notify = () => { +- const approvals = work.getSnapshot().approvals ?? []; ++ const { approvals = [], ended = [] } = work.getSnapshot(); ++ const events = [ ++ ...approvals.map((entry) => ({ entry, kind: "approval" })), ++ ...ended.map((entry) => ({ entry, kind: entry.failed === void 0 ? "finished" : "failed" })) ++ ]; ++ const key = ({ entry, kind }) => `${kind === "approval" ? "approval" : "turn"}-${entry.id}`; ++ const current = new Set(events.map(key)); + for (const id of told) { +- if (!approvals.some((entry) => entry.id === id)) told.delete(id); ++ if (!current.has(id)) told.delete(id); + } + if (typeof Notification !== "function" || Notification.permission !== "granted") return; +- for (const entry of approvals) { +- if (told.has(entry.id)) continue; +- told.add(entry.id); +- if ((entry.at ?? 0) < since2) continue; ++ const { byId, roomsById, prefs } = roster.getSnapshot(); ++ const on = prefs?.notifications !== false; ++ for (const event of events) { ++ const id = key(event); ++ if (told.has(id)) continue; ++ told.add(id); ++ const { entry, kind } = event; ++ if (!on || (entry.at ?? 0) < since2) continue; + const place = entry.roomId ?? entry.sessionId; + if (document.visibilityState === "visible" && shownSession() === place) continue; +- const { byId, roomsById } = roster.getSnapshot(); + 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 note = new Notification(title, { ++ body: [room2?.name, kind === "approval" ? entry.tool : void 0].filter(Boolean).join(" · "), ++ tag: `ds-bot-${id}` + }); + note.onclick = () => { + window.focus(); @@ -13247,11 +13247,45 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since return work.subscribe(notify); } diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index c19ae909..59449b2a 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -179,14 +179,17 @@ diff --git a/src/client/diagnostics.js b/src/client/diagnostics.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -5,6 +5,7 @@ import { allThemes } from './themes.js' +@@ -5,8 +5,9 @@ import { allThemes } from './themes.js' import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' +import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js' import { useEscape } from './overlay-hooks.js' - import { BotSettingsForm } from './bot-settings.js' +-import { BotSettingsForm } from './bot-settings.js' ++import { BotSettingsForm, SwitchRow } from './bot-settings.js' import { Segmented, useGlide } from './motion.js' + import { GroupSettingsForm, NewGroupForm } from './group-settings.js' + import { CreateBot } from './create-bot.js' @@ -23,7 +23,7 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list @@ -200,17 +203,24 @@ diff --git a/src/client/settings.js b/src/client/settings.js children ? h('div', { className: 'bt-set-control' }, children) : null) } -+// General is Appearance only; the switch to classic Agent mode is the account menu's (#393). ++// 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,6 @@ function GeneralPage({ roster, actions, run }) { +@@ -61,9 +61,13 @@ 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). ++ 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 })), ++ })), ] } diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 0784b528..d0948347 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -181,6 +181,7 @@ export function install(rt) { bots: pick(full.work.bots), rooms: Object.fromEntries(Object.entries(full.work.rooms ?? {}).filter(([id]) => roomMemberOf(actor, id) || roomOwnedBy(actor, id))), approvals: full.work.approvals.filter(entry => mine(entry.botId)), + ended: (full.work.ended ?? []).filter(entry => mine(entry.botId)), }, memoryNews: pick(full.memoryNews), memoryReviews: alone() ? full.memoryReviews : 0, diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index aca83a45..9e6cbffe 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -309,7 +309,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as } const fullLive = { activity: { a1: 'busy', b1: 'idle' }, - work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }] }, + work: { bots: { a1: {}, b1: {} }, rooms: {}, approvals: [{ botId: 'a1' }, { botId: 'b1' }], ended: [{ botId: 'a1' }, { botId: 'b1' }] }, memoryNews: { a1: 1, b1: 2 }, memoryReviews: 3, connectors: [{ id: 'c' }], @@ -339,6 +339,7 @@ test('views keep the sole owner whole and narrow per owner with two members', as const live2 = rt.ownedLive(fullLive, solo) assert.deepEqual(Object.keys(live2.activity), ['a1']) assert.deepEqual(live2.work.approvals.map(entry => entry.botId), ['a1']) + assert.deepEqual(live2.work.ended.map(entry => entry.botId), ['a1']) assert.deepEqual(live2.connectors, []) assert.equal(live2.memoryReviews, 0) }) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 55bc9ab9..ef18bd67 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -31,7 +31,9 @@ "test/connectors.test.js", "test/work-dir.test.js", "src/client/schedules.js", - "README.md" + "README.md", + "src/client/approval-notices.js", + "test/approval-notices.test.js" ], "0002-flybot-api-host": [ "index.js", From 2d54af3da4fc577e51e7bdbc009d591307f34699 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:01:48 +0800 Subject: [PATCH 2/2] Document ended turns for notifications in core-host (#353) --- docs/subsystems/core-host.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 6869578f..31c9715e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -133,6 +133,10 @@ credentials and team mode remain separate work under #55/#198. - `rt.testModel(botId)` (`models.js`, endpoint `test-model`, Bot-owner only in `authz.js` `BOT_TARGETED`) sends one real request on the route the Bot's next run takes (`desiredModel`): a one-line prompt, `maxTokens` 16, the model's least thinking (`off`, else its first level), a 30 s timeout, tagged to the Bot's chat as `other` usage. Nothing retries it, and a Bot runs at most one test at a time (a second click is refused). Any non-error finish, a reply cut off at the limit included, is a pass; a stream with no finish is `UNKNOWN` (`{ ok: true, ref, ms }`). A failure is `{ ok: false, ref, code }`, where `testFailure` maps the DSH failure (code, status, a missing-key message) to a fixed code; the provider's message is never returned or logged, since it may echo a key. A Bot with no model answers `NO_MODEL` without a request; a friend's Bot is refused. +### Ended turns for notifications (#353) + +- `turns.js` keeps `endedView()`: the last 20 turns 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). + ### Diagnostics - Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md).