Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/353-finish-fail-notifications.added.md
Original file line number Diff line number Diff line change
@@ -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: <reason>`), 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)).
47 changes: 37 additions & 10 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,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": "等你批准",
Expand Down Expand Up @@ -13628,6 +13632,18 @@ function GeneralPage({ roster, actions, run }) {
)
)
)
),
// One switch for every system notification: approvals waiting, Bots finished or failed (#353).
(0, import_react33.createElement)(
SetSection,
{ key: "notify", title: t("Notifications") },
(0, import_react33.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 }))
})
)
];
}
Expand Down Expand Up @@ -16488,22 +16504,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();
Expand Down
44 changes: 30 additions & 14 deletions core/src/client/approval-notices.js
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,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': '等你批准',
Expand Down
11 changes: 9 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -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 })),
})),
]
}

Expand Down
1 change: 1 addition & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
}

Expand Down
28 changes: 27 additions & 1 deletion core/src/host/turns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}
Loading
Loading