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/469-waiting-reminder-toggles.added.md
Original file line number Diff line number Diff line change
@@ -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)).
73 changes: 57 additions & 16 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} 等你批准",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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") },
Expand All @@ -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 } }))
}))
)
];
}
Expand Down Expand Up @@ -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
Expand Down
70 changes: 50 additions & 20 deletions core/src/client/approval-notices.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} 等你批准',
Expand Down
14 changes: 12 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}
Expand Down Expand Up @@ -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 } })),
}))),
]
}

Expand Down
14 changes: 14 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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()
}

Expand Down
Loading
Loading