From f5809490f438c2db80e84cfeb85c1d8487dc8a7e Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:00:29 +0800 Subject: [PATCH] Agent inbox: failed turns from the ended-turn events, and a latest-activity line per card (#471) --- changelog.d/471-inbox-failed-turns.added.md | 1 + core/client.js | 44 +- core/src/client/i18n.js | 2 + core/src/client/inbox.js | 50 +- core/src/host/turns.js | 21 +- core/test/approval-notices.test.js | 5 + core/test/inbox.test.js | 78 +++ docs/subsystems/core-client.md | 7 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 34 +- patches/ds-bot/0008-client-bundle.patch | 583 ++++++++++++-------- patches/ds-bot/files/src/client/inbox.js | 50 +- patches/ds-bot/files/test/inbox.test.js | 78 +++ 13 files changed, 709 insertions(+), 246 deletions(-) create mode 100644 changelog.d/471-inbox-failed-turns.added.md diff --git a/changelog.d/471-inbox-failed-turns.added.md b/changelog.d/471-inbox-failed-turns.added.md new file mode 100644 index 00000000..45dedb39 --- /dev/null +++ b/changelog.d/471-inbox-failed-turns.added.md @@ -0,0 +1 @@ +The Agent inbox now lists a turn of your own Bot that failed under **Needs you**, with the reason, until the Bot finishes a turn in that chat again, from the same events as the finished/failed notifications; and each inbox card shows a one-line **Latest:** row with what happened last in its chat ([#471](https://github.com/ThinkFlowLab/FlyBot/issues/471)). diff --git a/core/client.js b/core/client.js index a5051ae9..4820b7e5 100644 --- a/core/client.js +++ b/core/client.js @@ -613,6 +613,8 @@ var ZH = { "1 message waits for your approval": "1 条消息等你批准", "{count} messages wait for your approval": "{count} 条消息等你批准", "Approve it in the chat": "请在对话里批准", + "The last turn did not finish": "上一轮没有完成", + "Latest: {text}": "最新:{text}", "Untitled task": "未命名任务", "for {time}": "已 {time}", "just started": "刚开始", @@ -6611,6 +6613,19 @@ var roomName = (roster, id) => roster.roomsById?.[id]?.name; var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); +function latestActivity(card, { roster, work, sessions }) { + const options = []; + if (card.kind === "task") options.push(lastWhy(card.task)); + if (card.kind === "run" || card.kind === "task") { + const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; + if (entry) options.push(`${workPhrase(entry.tool)}…`); + } + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))); + return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; +} function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) { const needs = []; const progress = []; @@ -6676,6 +6691,29 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }; for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); + for (const entry of work.ended ?? []) { + const key = placeKey(entry.botId, entry.roomId); + if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); + } + for (const [key, entry] of lastEnded) { + const bot = ownBot(roster, entry.botId); + if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; + const chat = entry.roomId ?? bot.id; + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ""; + needs.push({ + id: `turn:${entry.id}`, + kind: "turn", + status: "failed", + bot, + at: entry.at, + title: asked || t("The last turn did not finish"), + reason: entry.failed, + place: placeOf(roster, entry.roomId), + chat, + ...people(roster, bot.id) + }); + } for (const task of tasks) { const bot = ownBot(roster, task.ownerBotId); if (bot === void 0) continue; @@ -6700,6 +6738,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() if (sessions?.byId) { for (const bot of roster.bots ?? []) { if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; + if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; const part = currentPart(bot); const at = sessions.byId[part]?.updatedAt; if (!fresh(at)) continue; @@ -6709,6 +6748,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() } } const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions }); return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) }; } var needsCount = (roster, work, tasks) => inboxModel({ roster, work, tasks }).needs.length; @@ -6764,6 +6804,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { ), (0, import_react11.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), card.reason ? (0, import_react11.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, + card.activity ? (0, import_react11.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, card.hint ? (0, import_react11.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, error ? (0, import_react11.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null ), @@ -6888,7 +6929,8 @@ var INBOX_CSS = ` .bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} .bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-activity{color:var(--bt-ink-3)} .bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} .bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} .bt-inbox-actions .bt-send{height:28px;font-size:12px} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 7832798d..2f349e39 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -554,6 +554,8 @@ const ZH = { '1 message waits for your approval': '1 条消息等你批准', '{count} messages wait for your approval': '{count} 条消息等你批准', 'Approve it in the chat': '请在对话里批准', + 'The last turn did not finish': '上一轮没有完成', + 'Latest: {text}': '最新:{text}', 'Untitled task': '未命名任务', 'for {time}': '已 {time}', 'just started': '刚开始', diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index 02d58a78..832d6162 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -2,7 +2,8 @@ // done across my own Bots, opened from the Inbox row at the top of the sidebar. Built // only from data the client already has: the work snapshot's approvals and running // turns, the held-message counts on chats, the Bot chats' last activity and the task -// records (src/host/tasks.js). No new host state. A tool approval is answered with the +// records (src/host/tasks.js), and the ended turns the finish/fail notifications use +// (work.ended, #353) for failed turns (#471). A tool approval is answered with the // same answer the group chat's card gives (actions.answerApproval); everything else, // and every Bot-sent message, is approved in its chat, so its card only has Open. import { createElement as h, useCallback, useEffect, useState } from 'react' @@ -94,6 +95,27 @@ const roomName = (roster, id) => roster.roomsById?.[id]?.name const placeOf = (roster, roomId) => (roomId && roomName(roster, roomId) ? t('in {name}', { name: roomName(roster, roomId) }) : '') const lastWhy = task => [...(task.actors ?? [])].reverse().find(entry => typeof entry.why === 'string' && entry.why !== '')?.why ?? '' const chatOfTask = task => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId +const placeKey = (botId, roomId) => `${botId}|${roomId ?? ''}` +// The Session a card's chat reads from: a group's own, a Bot's current part, else the id itself. +const partOf = (roster, chat) => (roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat])) +// What a Bot runs in a place right now: its own chat's turn or its turn in that group. +const runningIn = (work, botId, roomId) => (roomId === undefined + ? (work.bots?.[botId]?.roomId === undefined ? work.bots?.[botId] : undefined) + : (work.rooms?.[roomId] ?? []).find(entry => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : undefined)) + +// The card's one-line latest activity (#471): a task's newest note, else what its Bot runs +// there now (not for a card that waits for me), else that chat's last line. Left out when it +// only repeats the title or the reason. +function latestActivity(card, { roster, work, sessions }) { + const options = [] + if (card.kind === 'task') options.push(lastWhy(card.task)) + if (card.kind === 'run' || card.kind === 'task') { + const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined + if (entry) options.push(`${workPhrase(entry.tool)}…`) + } + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))) + return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' +} /** * The three groups of cards, newest first in each. @@ -146,6 +168,25 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date } for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId) for (const [roomId, entries] of Object.entries(work.rooms ?? {})) for (const entry of entries) running(entry.botId, entry, roomId) + // Failed turns (#471), from the ended turns the finish/fail notifications read (#353): a + // failure needs me while it is the latest ended turn in its place (a Bot's own chat or one + // group) and the Bot is not working or waiting there again. A later finished turn clears it. + const lastEnded = new Map() + for (const entry of work.ended ?? []) { + const key = placeKey(entry.botId, entry.roomId) + if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry) + } + for (const [key, entry] of lastEnded) { + const bot = ownBot(roster, entry.botId) + if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue + const chat = entry.roomId ?? bot.id + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : '' + needs.push({ + id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at, + title: asked || t('The last turn did not finish'), reason: entry.failed, + place: placeOf(roster, entry.roomId), chat, ...people(roster, bot.id), + }) + } // Task records (#54). for (const task of tasks) { const bot = ownBot(roster, task.ownerBotId) @@ -164,6 +205,8 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date if (sessions?.byId) { for (const bot of roster.bots ?? []) { if (ownBot(roster, bot.id) === undefined || work.bots?.[bot.id] !== undefined || waitingAt.has(`${bot.id}|`)) continue + // Its last turn failed: that card is in Needs you. + if (lastEnded.get(placeKey(bot.id))?.failed !== undefined) continue const part = currentPart(bot) const at = sessions.byId[part]?.updatedAt if (!fresh(at)) continue @@ -173,6 +216,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date } } const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity) + for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions }) return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) } } @@ -220,6 +264,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null, + card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null, card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null, error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null), h('span', { className: 'bt-inbox-actions' }, @@ -322,7 +367,8 @@ export const INBOX_CSS = ` .bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} .bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-activity{color:var(--bt-ink-3)} .bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} .bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} .bt-inbox-actions .bt-send{height:28px;font-size:12px} diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 6f031bcd..f653c417 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -254,11 +254,22 @@ export function install(rt) { } // 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. + // (#353) and the Agent inbox's failed turns (#471). 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. Past the last 20, a place's (Bot + group) latest turn is kept while it is a + // failure younger than 7 days, so busy Bots elsewhere do not push it out of the inbox. const ENDED_KEPT = 20 + const FAILED_KEPT = 50 + const FAILED_MS = 7 * 86_400_000 let ended = [] + const placeOfEnded = entry => `${entry.botId}|${entry.roomId ?? ''}` + const trimEnded = (list, now) => { + const latest = new Map(list.map(entry => [placeOfEnded(entry), entry])) + const older = list.slice(0, -ENDED_KEPT) + .filter(entry => entry.failed !== undefined && latest.get(placeOfEnded(entry)) === entry && now - entry.at <= FAILED_MS) + return [...older.slice(-FAILED_KEPT), ...list.slice(-ENDED_KEPT)] + } const noteEnded = (id, turn, info, reason, failure) => { if (failure === undefined && reason?.kind !== 'completed') return const owner = rt.groupOwners.get(id) @@ -268,13 +279,13 @@ export function install(rt) { 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, { + ended = trimEnded([...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) + }], at) } const endedView = () => ended diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index 99e281e4..a05c7d60 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -168,5 +168,10 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], ]) for (let n = 10; n < 40; n++) turn('b', n, completed) + // Past the last 20, the failure in r1 stays: it is still the latest turn there (#471). + assert.deepEqual(rt.endedView().map(entry => entry.id.split(':').slice(0, 2).join(':')), ['g1:1', ...Array.from({ length: 20 }, (_, i) => `b:${20 + i}`)]) + turn('g1', 2, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1y' }) + for (let n = 40; n < 60; n++) turn('b', n, completed) assert.equal(rt.endedView().length, 20) + assert.ok(rt.endedView().every(entry => entry.failed === undefined)) }) diff --git a/core/test/inbox.test.js b/core/test/inbox.test.js index fee31567..86d96293 100644 --- a/core/test/inbox.test.js +++ b/core/test/inbox.test.js @@ -256,3 +256,81 @@ test('the sidebar has one Inbox row at its top, and the dialog is an overlay', ( assert.ok(sidebar.indexOf('h(InboxRow,') < sidebar.indexOf("className: 'bt-list'")) assert.match(readFileSync(new URL('../src/client/overlays.js', import.meta.url), 'utf8'), /h\(InboxDialog,/) }) + +// #471: failed turns from the ended turns the finish/fail notifications use (#353), and a +// one-line latest activity on each card. +const endedWork = { + ...work, + ended: [ + { id: 'e1', botId: 'b2', sessionId: 's2', at: NOW - 10 * 60_000 }, + { id: 'e2', botId: 'b2', sessionId: 's2', at: NOW - 5 * 60_000, failed: 'upstream busy' }, + { id: 'e3', botId: 'b3', roomId: 'r1', at: NOW - 3 * 60_000, failed: 'quota' }, + { id: 'e4', botId: 'b1', sessionId: 's1', at: NOW - 4 * 60_000, failed: 'timeout' }, + { id: 'e5', botId: 'f1', sessionId: 'f1', at: NOW - 60_000, failed: 'friend side' }, + { id: 'e6', botId: 'b3', sessionId: 's3', at: NOW - 8 * DAY, failed: 'too old' }, + { id: 'e7', botId: 'b1', roomId: 'r2', at: NOW - 9 * 60_000, failed: 'gone' }, + { id: 'e8', botId: 'b1', roomId: 'r2', at: NOW - 2 * 60_000 }, + ], +} +const endedSessions = { + byId: { + ...sessions.byId, + r1: { updatedAt: NOW - 60_000, projectionValues: { turnOutline: [{ turn: 4, prompt: 'Ship the release notes', response: '' }] } }, + }, +} +const endedModel = inboxModel({ roster, work: endedWork, tasks, sessions: endedSessions, now: NOW }) + +test('a failed turn of my own Bot needs me while it is the latest turn in its chat and the Bot is not back at work there', () => { + const turns = endedModel.needs.filter(card => card.kind === 'turn') + // b3 in Ops waits for approval a1 and b1 runs again in its own chat; r2's failure was + // followed by a finished turn; f1 is a friend's Bot; e6 is older than 7 days. + assert.deepEqual(plain(ids(turns)), ['turn:e2']) + const [card] = turns + assert.equal(card.status, 'failed') + assert.equal(card.reason, 'upstream busy') + assert.equal(card.chat, 'b2') + assert.equal(card.title, 'Here is the weekly summary of everything that changed') + assert.equal(card.executingBot, 'b2') + // The chat whose last turn failed is not also Done. + assert.ok(!ids(endedModel.done).includes('chat:b2')) + assert.equal(needsCount(roster, endedWork, tasks), model.needs.length + 1) + // Without the Session list (the row's badge), the title says what happened. + assert.equal(inboxModel({ roster, work: endedWork, tasks, now: NOW }).needs.find(entry => entry.id === 'turn:e2').title, 'The last turn did not finish') + assert.deepEqual(plain(buttons(draw(card)).map(textOf)), ['Open']) +}) + +test('a failure no longer needs me once the Bot works or waits there again', () => { + const again = inboxModel({ roster, work: { ...endedWork, bots: { ...endedWork.bots, b2: { since: NOW - 1000 } } }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(!ids(again.needs).includes('turn:e2')) + const freed = inboxModel({ roster, work: { ...endedWork, approvals: [], rooms: {} }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(ids(freed.needs).includes('turn:e3')) + assert.equal(freed.needs.find(card => card.id === 'turn:e3').place, 'in Ops') +}) + +test('each card has a one-line latest activity that never repeats its title or reason', () => { + const byId = Object.fromEntries([...endedModel.needs, ...endedModel.progress, ...endedModel.done].map(card => [card.id, card])) + // An approval and a running turn in Ops show that chat's last line. + assert.equal(byId['approval:a1'].activity, 'Ship the release notes') + assert.equal(byId['run:b1|r1'].activity, 'Ship the release notes') + // A held message in b2's chat shows its last line; a task its newest note. + assert.equal(byId['held:b2'].activity, 'Here is the weekly summary of everything that changed') + const noted = inboxModel({ roster, work, tasks: [task('t10', 'working', { actors: [{ status: 'claimed', why: 'Reading the spec' }] })], now: NOW }) + assert.equal(noted.progress.find(card => card.id === 'task:t10').activity, 'Reading the spec') + // A running task's Bot shows the tool it runs when there is no note. + const busy = inboxModel({ roster, work: { bots: { b2: { since: NOW - 1000, tool: 'pwsh' } } }, tasks: [task('t11', 'working')], now: NOW }) + assert.match(busy.progress.find(card => card.id === 'task:t11').activity, /…$/) + // Repeats are left out: the failed turn's title is its chat's last line, the failed task's + // reason its newest note (so its chat's last line shows instead). + assert.equal(byId['turn:e2'].activity, '') + assert.equal(byId['task:t2'].activity, 'Here is the weekly summary of everything that changed') + assert.equal(inboxModel({ roster, work: {}, tasks, sessions, now: NOW }).done.find(card => card.id === 'chat:b2').activity, '') + for (const card of Object.values(byId)) { + assert.ok(card.activity !== card.title || card.activity === '', card.id) + const rows = byClass(draw(card), 'bt-inbox-activity') + assert.equal(rows.length, card.activity ? 1 : 0, card.id) + if (card.activity) { + assert.equal(rows[0].props.title, card.activity) + assert.equal(textOf(rows[0]), `Latest: ${card.activity}`) + } + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e07f093a..a776f41e 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -30,7 +30,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept) and **Pause** / **Resume**; a paused row reads **Paused** (#354). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). Tests: `core/test/schedules-client.test.js`. | -| `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`). Tests: `core/test/inbox.test.js`. | +| `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns and a *Latest* line per card (#471). Tests: `core/test/inbox.test.js`. | ## Invariants @@ -104,9 +104,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - One **Inbox** row sits at the top of the sidebar, above the Bot list (wide and rail), with a count badge of the *Needs you* cards; it opens the inbox dialog over the conversation (owner decision 2026-10-20, option a). No new + menu entry, no new sidebar section. - Groups, in this order and with these words (the decision's "Need you" is written **Needs you**, as in the triage): **Needs you**, **In progress**, **Done**; each has a one-line empty message. Only data the client already has, no new host state: `work.approvals` (tool approvals), `pendingCount` on chats (held Bot messages), `work.bots` / `work.rooms` (running turns), the task records of `src/host/tasks.js` (`actions.tasks` → the existing `tasks` endpoint, polled every 30 s) and the Session list's `updatedAt` and last line for finished Bot chats. - Only my own Bots (not a friend's Bot, not another member's): a friend's chat appears only as a held message of mine waiting in it. Every card carries `assigner`, `owner` and `executingBot` (the first two are me in step 1) so team views can filter later. -- *Needs you*: tool approvals, held messages, tasks that are blocked, have a result ready or failed (with the reason). *In progress*: running turns (with elapsed time; a turn waiting for an approval shows only in *Needs you*) and open/claimed/working tasks. *Done*: accepted or cancelled tasks and Bot chats whose last turn finished. Done and failed tasks age out after 7 days; Done shows the newest 20. +- *Needs you*: tool approvals, held messages, tasks that are blocked, have a result ready or failed (with the reason), and failed turns (#471, `kind: 'turn'`): from `work.ended`, the same ended-turn entries the system notifications read (#353), a place's (a Bot's own chat or one group) latest ended turn when it failed, in the last 7 days, while that Bot is not running or waiting for an approval there; its title is the chat's last line (*The last turn did not finish* without the Session list), its reason the failure. A later finished turn there clears it, and that Bot chat is then not also in *Done*. **Open** only. *In progress*: running turns (with elapsed time; a turn waiting for an approval shows only in *Needs you*) and open/claimed/working tasks. *Done*: accepted or cancelled tasks and Bot chats whose last turn finished. Done and failed tasks age out after 7 days; Done shows the newest 20. - Each card has exactly one status, as a colored pill with an icon and a word (never color alone), the Bot, where and when, the title on one line with the full text on hover, and **Open** (closes the inbox, opens the chat). An approval the group chat's card can answer (`answerable`) also has **Reject** / **Allow once**, which call `actions.answerApproval`, the same answer as that card (no second approval path); the card leaves and a status line says what was done; an error stays on the card. No native confirm dialog. A held Bot message, an approval DSH's card answers in the Bot's own chat, and every task have **Open** only: Bot-sent messages are approved in their chat. Every action is a button, so the dialog is keyboard reachable; the close button takes focus and Escape closes. -- Out of step 1: the three-column board, tool-call counts, failed turns without a task record (they need an event source, #353), team views (#51/#149). +- **Latest activity (#471):** every card may have one more line, *Latest: …* (`card.activity`, `bt-inbox-activity`, full text on hover): a task's newest note, else what its Bot runs there now (only for running turns and tasks), else the chat's last line (`previewOf`); left out when it only repeats the title or the reason. +- Out of scope so far (#471): the three-column board, tool-call counts, a first-run guide, team views (#51/#149/#55/#57). ### Approval wait badge on chat rows diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index da72f545..3ae70ae4 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -143,7 +143,7 @@ credentials and team mode remain separate work under #55/#198. ### 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). +- `turns.js` keeps `endedView()` (`trimEnded`): the last 20 turns, plus, past those, each place's (Bot + group) latest turn while it is a failure younger than 7 days (at most 50, for the Agent inbox's failed turns, #471), of the user's own Bots that finished (`reason.kind === 'completed'`) or failed (`failed`: the failure's message, else its code, at most 160 characters; an AUTH message stays out as in `turnFailure`), as `{ id: '::', botId, sessionId | roomId, at }`. Process-local, never saved. Not listed: a turn stopped, switched to a new part or retried for a tool call written as text; a friend's Bot (`bot.remote`); a turn a friend started (`fromRemote`, a remote-chat line); a hidden turn another Bot asked for (`exchangeTurns`); a group turn in a room with an `origin` (face-to-face, inbound-share). It rides `live().work.ended`; `rt.ownedLive` keeps the actor's own Bots' entries. `set-prefs` takes `notifications: false` (kept) or `true` / `null` (cleared: on by default). The client's use is in [core-client](core-client.md#system-notifications-353). ### Diagnostics diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ad614280..b9b5656b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -692,7 +692,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'You have {version}.': '当前版本 {version}。', 'The update did not finish: {reason}': '更新没有完成:{reason}', "What's new": '更新说明', -@@ -360,9 +360,34 @@ const ZH = { +@@ -360,9 +360,36 @@ const ZH = { 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -719,6 +719,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '1 message waits for your approval': '1 条消息等你批准', + '{count} messages wait for your approval': '{count} 条消息等你批准', + 'Approve it in the chat': '请在对话里批准', ++ 'The last turn did not finish': '上一轮没有完成', ++ 'Latest: {text}': '最新:{text}', + 'Untitled task': '未命名任务', + 'for {time}': '已 {time}', + 'just started': '刚开始', @@ -2919,16 +2921,27 @@ diff --git a/src/host/turns.js b/src/host/turns.js markExchangeTurn(sessionId, entry.turn) } } -@@ -238,6 +238,31 @@ export function install(rt) { +@@ -238,6 +238,42 @@ 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. ++ // (#353) and the Agent inbox's failed turns (#471). 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. Past the last 20, a place's (Bot + group) latest turn is kept while it is a ++ // failure younger than 7 days, so busy Bots elsewhere do not push it out of the inbox. + const ENDED_KEPT = 20 ++ const FAILED_KEPT = 50 ++ const FAILED_MS = 7 * 86_400_000 + let ended = [] ++ const placeOfEnded = entry => `${entry.botId}|${entry.roomId ?? ''}` ++ const trimEnded = (list, now) => { ++ const latest = new Map(list.map(entry => [placeOfEnded(entry), entry])) ++ const older = list.slice(0, -ENDED_KEPT) ++ .filter(entry => entry.failed !== undefined && latest.get(placeOfEnded(entry)) === entry && now - entry.at <= FAILED_MS) ++ return [...older.slice(-FAILED_KEPT), ...list.slice(-ENDED_KEPT)] ++ } + const noteEnded = (id, turn, info, reason, failure) => { + if (failure === undefined && reason?.kind !== 'completed') return + const owner = rt.groupOwners.get(id) @@ -2938,13 +2951,13 @@ diff --git a/src/host/turns.js b/src/host/turns.js + 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, { ++ ended = trimEnded([...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) ++ }], at) + } + const endedView = () => ended + @@ -3192,7 +3205,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js const module = { exports: {} } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, Notification, -@@ -62,3 +62,109 @@ test('no notification for the conversation on screen, for approvals older than t +@@ -62,3 +62,114 @@ 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) }) @@ -3300,7 +3313,12 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + ['g1:1', { botId: 'b', roomId: 'r1', failed: 'upstream busy' }], + ]) + for (let n = 10; n < 40; n++) turn('b', n, completed) ++ // Past the last 20, the failure in r1 stays: it is still the latest turn there (#471). ++ assert.deepEqual(rt.endedView().map(entry => entry.id.split(':').slice(0, 2).join(':')), ['g1:1', ...Array.from({ length: 20 }, (_, i) => `b:${20 + i}`)]) ++ turn('g1', 2, completed, { kind: SOURCE_KIND, role: 'room', exchangeId: 'r1y' }) ++ for (let n = 40; n < 60; n++) turn('b', n, completed) + assert.equal(rt.endedView().length, 20) ++ assert.ok(rt.endedView().every(entry => entry.failed === undefined)) +}) diff --git a/test/client-bundle.test.js b/test/client-bundle.test.js --- a/test/client-bundle.test.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c1f83416..40d22077 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -333,7 +333,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,33 @@ var ZH = { +@@ -420,8 +420,35 @@ var ZH = { "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -359,6 +359,8 @@ diff --git a/client.js b/client.js + "1 message waits for your approval": "1 条消息等你批准", + "{count} messages wait for your approval": "{count} 条消息等你批准", + "Approve it in the chat": "请在对话里批准", ++ "The last turn did not finish": "上一轮没有完成", ++ "Latest: {text}": "最新:{text}", + "Untitled task": "未命名任务", + "for {time}": "已 {time}", + "just started": "刚开始", @@ -1030,6 +1032,7 @@ diff --git a/client.js b/client.js - value = resolved; - for (const listener of [...listeners2]) listener(); - } +- }; +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1066,7 +1069,21 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +-var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +-function indexRoster(value) { +- const byId = {}; +- for (const bot of value.bots) { +- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +- } +- const roomsById = {}; +- for (const room2 of value.rooms) roomsById[room2.id] = room2; +- const agentsById = {}; +- const agentOf = {}; +- for (const agent of value.agents ?? []) { +- agentsById[agent.id] = agent; +- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -1092,30 +1109,35 @@ diff --git a/client.js b/client.js + onAnimationEnd: poked ? (event) => { + if (/^bt-poke/.test(event.animationName)) setPoked(false); + } : void 0 - }; ++ }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); -+ } + } +- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } --var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; --var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); --function indexRoster(value) { -- const byId = {}; -- for (const bot of value.bots) { -- for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; +-function isAgentSession(roster, cache, sessionId) { +- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; +- if (roster.ready) return roster.agentOf[sessionId] !== void 0; +- return cache.has(sessionId); +function StarIcon({ size = 8 }) { + return (0, import_react.createElement)( + "svg", + { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, + (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + ); -+} + } +-function countCards(state, sessionId, callIds, delta) { +- const own = { ...state[sessionId] }; +- for (const callId of callIds) { +- const count = (own[callId] ?? 0) + delta; +- if (count > 0) own[callId] = count; +- else delete own[callId]; +var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); +var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); +function PersonMark({ size = 22, name }) { @@ -1128,13 +1150,7 @@ diff --git a/client.js b/client.js + "aria-hidden": true + }, initial); } -- const roomsById = {}; -- for (const room2 of value.rooms) roomsById[room2.id] = room2; -- const agentsById = {}; -- const agentOf = {}; -- for (const agent of value.agents ?? []) { -- agentsById[agent.id] = agent; -- for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; +- return { ...state, [sessionId]: own }; + return (0, import_react.createElement)( + "span", + { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, @@ -1145,10 +1161,93 @@ diff --git a/client.js b/client.js + (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) + ) + ); -+} + } +- +-// src/client/themes.js +-var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +-var THEMES = { +- deepseek: { +- label: "DeepSeek", +- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, +- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } +- }, +- mono: { +- label: "Mono", +- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, +- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } +- }, +- paper: { +- label: "Paper", +- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, +- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } +- }, +- moonlight: { +- label: "Moonlight", +- 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" } +- } +-}; +-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) { +- return { +- "--bt-ink": mode.text, +- "--bt-ink-2": rgba(mode.text, 0.6), +- "--bt-ink-3": rgba(mode.text, 0.36), +- "--bt-bubble": mode.bubble, +- "--bt-sidebar": mode.sidebar, +- "--bt-main": mode.bg, +- "--bt-card": mode.card, +- "--bt-line": rgba(mode.text, 0.08), +- "--bt-line-2": rgba(mode.text, 0.15), +- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", +- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", +- "--bt-accent": accent, +- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", +- "--bt-user": mode.user, +- "--bt-user-ink": mode.userText, +- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), +- "--bt-tag-ink": rgba(mode.text, 0.58) +- }; +function AdminStar() { + return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} + } +-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))}} +-${theme.css ?? ""}`; +-var shellTokens = ({ light, dark }) => ({ +- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, +- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, +- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, +- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, +- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, +- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } +-}); +-var customThemes = {}; +-var registry = createSource(0); +-var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); +-function registerShape(id, shape) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); +- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); +- const at = shape.eyes?.at; +- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); +- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; +- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; +- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); +- const previous = SHAPES[id]; +- SHAPES[id] = { +- label: String(shape.label ?? id), +- size, +- body: shape.body, +- eyes, +- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} +- }; +- const refresh = () => { +- markupCache.clear(); +- maskCache.delete(id); +- registry.set((value) => value + 1); +- }; +- refresh(); +function MainStar() { + return (0, import_react.createElement)( + "svg", @@ -1194,15 +1293,10 @@ diff --git a/client.js b/client.js + const size = frame * 5 / 9; + const rest = frame - size; + return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; - } -- return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; ++ } + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function isAgentSession(roster, cache, sessionId) { -- if (sessionId === void 0 || sessionId === null || sessionId === "") return false; -- if (roster.ready) return roster.agentOf[sessionId] !== void 0; -- return cache.has(sessionId); ++} +function roomAvatarPeople(room2, roster) { + const members = (room2?.members ?? []).map((id) => roster?.byId?.[id]).filter(Boolean); + const seen = new Set(members.map((bot) => bot.id)); @@ -1235,13 +1329,7 @@ diff --git a/client.js b/client.js + } + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; - } --function countCards(state, sessionId, callIds, delta) { -- const own = { ...state[sessionId] }; -- for (const callId of callIds) { -- const count = (own[callId] ?? 0) + delta; -- if (count > 0) own[callId] = count; -- else delete own[callId]; ++} +function RoomAvatar({ room: room2, roster, size = 36 }) { + const people2 = roomAvatarPeople(room2, roster); + const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); @@ -1253,8 +1341,7 @@ diff --git a/client.js b/client.js + people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), + people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null + ); - } -- return { ...state, [sessionId]: own }; ++ } + const seats = clusterSeats(Math.min(people2.length, 4), size); + const overflow = people2.length > 4 ? people2.length - 3 : 0; + const gap = size / 14; @@ -1273,97 +1360,15 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// src/client/themes.js --var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; --var THEMES = { -- deepseek: { -- label: "DeepSeek", -- light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -- dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -- }, -- mono: { -- label: "Mono", -- light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -- dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -- }, -- paper: { -- label: "Paper", -- light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -- dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -- }, -- moonlight: { -- label: "Moonlight", -- 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" } -- } ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); +var openChats = /* @__PURE__ */ new Map(); +var changed = () => { + for (const fn of [...claimListeners]) fn(); - }; --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) { -- return { -- "--bt-ink": mode.text, -- "--bt-ink-2": rgba(mode.text, 0.6), -- "--bt-ink-3": rgba(mode.text, 0.36), -- "--bt-bubble": mode.bubble, -- "--bt-sidebar": mode.sidebar, -- "--bt-main": mode.bg, -- "--bt-card": mode.card, -- "--bt-line": rgba(mode.text, 0.08), -- "--bt-line-2": rgba(mode.text, 0.15), -- "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -- "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -- "--bt-accent": accent, -- "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -- "--bt-user": mode.user, -- "--bt-user-ink": mode.userText, -- "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -- "--bt-tag-ink": rgba(mode.text, 0.58) -- }; --} --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))}} --${theme.css ?? ""}`; --var shellTokens = ({ light, dark }) => ({ -- "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -- "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -- "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -- "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -- "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -- "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } --}); --var customThemes = {}; --var registry = createSource(0); --var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); --function registerShape(id, shape) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -- if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -- const at = shape.eyes?.at; -- if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -- const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -- const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -- const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -- const previous = SHAPES[id]; -- SHAPES[id] = { -- label: String(shape.label ?? id), -- size, -- body: shape.body, -- eyes, -- ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -- }; -- const refresh = () => { -- markupCache.clear(); -- maskCache.delete(id); -- registry.set((value) => value + 1); -- }; -- refresh(); ++}; +function chatOpened(sessionId, delta) { + const count = (openChats.get(sessionId) ?? 0) + delta; + if (count > 0) openChats.set(sessionId, count); @@ -1550,7 +1555,7 @@ diff --git a/client.js b/client.js + const inOpenChat = (approval) => { + const chat = chatOf(approval, roster); + return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); - }; ++ }; + const away = open.filter((approval) => policies.has(approval.by) && approval.status !== "failed" && !inOpenChat(approval)); + const awayKey = away.map((approval) => approval.id).join(","); + (0, import_react2.useEffect)(() => { @@ -1574,7 +1579,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } -+ }; + }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -1668,13 +1673,10 @@ diff --git a/client.js b/client.js // src/shared/mentions.js var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1443,7 +1443,28 @@ function mentionSpans(text, bots, { references = [] } = {}) { - spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); - at = end - 1; +@@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { } -- return spans; -+ return spans; -+} + return spans; + } +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ @@ -1695,9 +1697,10 @@ diff --git a/client.js b/client.js + if (word) missing.push(`@${word}`); + } + return missing; - } ++} // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; @@ -1463,6 +1463,19 @@ function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; @@ -1786,11 +1789,35 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1573,60 +1573,246 @@ function relaysBefore(contexts, steps, step) { + break; + } } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { +- return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++ return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); ++} ++function commRunOf(order, peers, turn, hiddenTurns = []) { ++ const turns = []; ++ for (const entry of order) { ++ const peer = peers[entry]; ++ const hidden = hiddenTurns.includes(entry); ++ if (hidden && peer === void 0) continue; ++ turns.push({ turn: entry, peer, hidden }); ++ } ++ const at = turns.findIndex((entry) => entry.turn === turn); ++ if (at === -1 || turns[at].peer === void 0) return null; ++ const previous = turns[at - 1]; ++ if (previous?.peer !== void 0 && previous.hidden) return "folded"; ++ let count = 0; ++ const peerIds = []; ++ for (let index = at; index < turns.length; index += 1) { ++ const entry = turns[index]; ++ if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; ++ count += entry.hidden ? 2 : 1; ++ if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); ++ } ++ return { count, peerIds }; ++} +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; @@ -1799,22 +1826,32 @@ diff --git a/client.js b/client.js +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); +function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { - 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(); ++ 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 = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; + if (text !== "") return stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1624,9 +1624,188 @@ function usageOf(list, ids) { - const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); - } ++ } ++ return ""; ++} ++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; ++ } ++ 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 }); ++} +function faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); @@ -1874,7 +1911,26 @@ 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 commRunOf(order, peers, turn, hiddenTurns = []) { +- const turns = []; +- for (const entry of order) { +- const peer = peers[entry]; +- const hidden = hiddenTurns.includes(entry); +- if (hidden && peer === void 0) continue; +- turns.push({ turn: entry, peer, hidden }); +- } +- const at = turns.findIndex((entry) => entry.turn === turn); +- if (at === -1 || turns[at].peer === void 0) return null; +- const previous = turns[at - 1]; +- if (previous?.peer !== void 0 && previous.hidden) return "folded"; +- let count = 0; +- const peerIds = []; +- for (let index = at; index < turns.length; index += 1) { +- const entry = turns[index]; +- if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; +- count += entry.hidden ? 2 : 1; +- if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -1886,9 +1942,18 @@ 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 { count, peerIds }; + 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(); + +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1912,7 +1977,8 @@ diff --git a/client.js b/client.js + label: "Moonlight", + 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 ""; +}; +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) { @@ -1935,7 +2001,18 @@ 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))}} @@ -1983,7 +2060,10 @@ 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); @@ -1992,7 +2072,7 @@ diff --git a/client.js b/client.js + else delete customThemes[id]; + registry3.set((value) => value + 1); + }; -+} + } +var allThemes = () => ({ ...THEMES, ...customThemes }); // src/client/motion.js @@ -2329,10 +2409,17 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2852,229 +2852,211 @@ function installCompiledStyles(text, tag) { - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); - style.remove(); +@@ -2845,236 +2845,218 @@ function installCompiledStyles(text, tag) { + if (event.target instanceof HTMLLinkElement) schedule(); + }; + const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); - }; -} - @@ -2419,8 +2506,7 @@ diff --git a/client.js b/client.js - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); -+ }; - } +-} -function useSettled(state) { - const prev = (0, import_react2.useRef)(state); - const [settled, setSettled] = (0, import_react2.useState)(false); @@ -2437,6 +2523,28 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; ++ observer.observe(document.head, { childList: true }); ++ document.addEventListener("load", onLoad, true); ++ return () => { ++ observer.disconnect(); ++ document.removeEventListener("load", onLoad, true); ++ clearTimeout(timer); ++ style.remove(); ++ }; + } +-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"]; @@ -2469,19 +2577,13 @@ 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 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 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 uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2491,7 +2593,9 @@ 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); +} @@ -2516,13 +2620,18 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); } --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 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 +- ); + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2550,24 +2659,28 @@ diff --git a/client.js b/client.js + isChief: bot.remote.isChief + }); } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) +- ); + 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 () => { + }; @@ -2597,25 +2710,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3849,7 +3944,8 @@ diff --git a/client.js b/client.js { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, - (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -3944,8 +4040,7 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); -- return (0, import_react6.createElement)( ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); @@ -4372,8 +4467,7 @@ 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)); @@ -4390,7 +4484,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }), pending.map((id) => { @@ -4788,7 +4883,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,440 @@ function showsDshRow(dsh, app) { +@@ -4731,49 +4731,480 @@ function showsDshRow(dsh, app) { return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -4893,6 +4988,19 @@ diff --git a/client.js b/client.js +var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; +var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; +var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; ++var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; ++var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); ++var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); ++function latestActivity(card, { roster, work, sessions }) { ++ const options = []; ++ if (card.kind === "task") options.push(lastWhy(card.task)); ++ if (card.kind === "run" || card.kind === "task") { ++ const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; ++ if (entry) options.push(`${workPhrase(entry.tool)}…`); ++ } ++ if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))); ++ return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; ++} +function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) { + const needs = []; + const progress = []; @@ -4958,6 +5066,29 @@ diff --git a/client.js b/client.js + }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); ++ const lastEnded = /* @__PURE__ */ new Map(); ++ for (const entry of work.ended ?? []) { ++ const key = placeKey(entry.botId, entry.roomId); ++ if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry); ++ } ++ for (const [key, entry] of lastEnded) { ++ const bot = ownBot(roster, entry.botId); ++ if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; ++ const chat = entry.roomId ?? bot.id; ++ const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ""; ++ needs.push({ ++ id: `turn:${entry.id}`, ++ kind: "turn", ++ status: "failed", ++ bot, ++ at: entry.at, ++ title: asked || t("The last turn did not finish"), ++ reason: entry.failed, ++ place: placeOf(roster, entry.roomId), ++ chat, ++ ...people(roster, bot.id) ++ }); ++ } + for (const task of tasks) { + const bot = ownBot(roster, task.ownerBotId); + if (bot === void 0) continue; @@ -4982,6 +5113,7 @@ diff --git a/client.js b/client.js + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; ++ if (lastEnded.get(placeKey(bot.id))?.failed !== void 0) continue; + const part = currentPart(bot); + const at = sessions.byId[part]?.updatedAt; + if (!fresh(at)) continue; @@ -4991,6 +5123,7 @@ diff --git a/client.js b/client.js + } + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); ++ for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions }); + return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) }; +} +var needsCount = (roster, work, tasks) => inboxModel({ roster, work, tasks }).needs.length; @@ -5046,6 +5179,7 @@ diff --git a/client.js b/client.js + ), + (0, import_react11.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), + card.reason ? (0, import_react11.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, ++ card.activity ? (0, import_react11.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, + card.hint ? (0, import_react11.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, + error ? (0, import_react11.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null + ), @@ -5170,7 +5304,8 @@ diff --git a/client.js b/client.js +.bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} +.bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-activity{color:var(--bt-ink-3)} +.bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} +.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} +.bt-inbox-actions .bt-send{height:28px;font-size:12px} diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index 02d58a78..832d6162 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -2,7 +2,8 @@ // done across my own Bots, opened from the Inbox row at the top of the sidebar. Built // only from data the client already has: the work snapshot's approvals and running // turns, the held-message counts on chats, the Bot chats' last activity and the task -// records (src/host/tasks.js). No new host state. A tool approval is answered with the +// records (src/host/tasks.js), and the ended turns the finish/fail notifications use +// (work.ended, #353) for failed turns (#471). A tool approval is answered with the // same answer the group chat's card gives (actions.answerApproval); everything else, // and every Bot-sent message, is approved in its chat, so its card only has Open. import { createElement as h, useCallback, useEffect, useState } from 'react' @@ -94,6 +95,27 @@ const roomName = (roster, id) => roster.roomsById?.[id]?.name const placeOf = (roster, roomId) => (roomId && roomName(roster, roomId) ? t('in {name}', { name: roomName(roster, roomId) }) : '') const lastWhy = task => [...(task.actors ?? [])].reverse().find(entry => typeof entry.why === 'string' && entry.why !== '')?.why ?? '' const chatOfTask = task => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId +const placeKey = (botId, roomId) => `${botId}|${roomId ?? ''}` +// The Session a card's chat reads from: a group's own, a Bot's current part, else the id itself. +const partOf = (roster, chat) => (roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat])) +// What a Bot runs in a place right now: its own chat's turn or its turn in that group. +const runningIn = (work, botId, roomId) => (roomId === undefined + ? (work.bots?.[botId]?.roomId === undefined ? work.bots?.[botId] : undefined) + : (work.rooms?.[roomId] ?? []).find(entry => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : undefined)) + +// The card's one-line latest activity (#471): a task's newest note, else what its Bot runs +// there now (not for a card that waits for me), else that chat's last line. Left out when it +// only repeats the title or the reason. +function latestActivity(card, { roster, work, sessions }) { + const options = [] + if (card.kind === 'task') options.push(lastWhy(card.task)) + if (card.kind === 'run' || card.kind === 'task') { + const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined + if (entry) options.push(`${workPhrase(entry.tool)}…`) + } + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))) + return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' +} /** * The three groups of cards, newest first in each. @@ -146,6 +168,25 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date } for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId) for (const [roomId, entries] of Object.entries(work.rooms ?? {})) for (const entry of entries) running(entry.botId, entry, roomId) + // Failed turns (#471), from the ended turns the finish/fail notifications read (#353): a + // failure needs me while it is the latest ended turn in its place (a Bot's own chat or one + // group) and the Bot is not working or waiting there again. A later finished turn clears it. + const lastEnded = new Map() + for (const entry of work.ended ?? []) { + const key = placeKey(entry.botId, entry.roomId) + if ((entry.at ?? 0) >= (lastEnded.get(key)?.at ?? -Infinity)) lastEnded.set(key, entry) + } + for (const [key, entry] of lastEnded) { + const bot = ownBot(roster, entry.botId) + if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue + const chat = entry.roomId ?? bot.id + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : '' + needs.push({ + id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at, + title: asked || t('The last turn did not finish'), reason: entry.failed, + place: placeOf(roster, entry.roomId), chat, ...people(roster, bot.id), + }) + } // Task records (#54). for (const task of tasks) { const bot = ownBot(roster, task.ownerBotId) @@ -164,6 +205,8 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date if (sessions?.byId) { for (const bot of roster.bots ?? []) { if (ownBot(roster, bot.id) === undefined || work.bots?.[bot.id] !== undefined || waitingAt.has(`${bot.id}|`)) continue + // Its last turn failed: that card is in Needs you. + if (lastEnded.get(placeKey(bot.id))?.failed !== undefined) continue const part = currentPart(bot) const at = sessions.byId[part]?.updatedAt if (!fresh(at)) continue @@ -173,6 +216,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date } } const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity) + for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions }) return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) } } @@ -220,6 +264,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), card.reason ? h('span', { className: 'bt-inbox-reason', title: card.reason }, card.reason) : null, + card.activity ? h('span', { className: 'bt-inbox-activity', title: card.activity }, t('Latest: {text}', { text: card.activity })) : null, card.hint ? h('span', { className: 'bt-inbox-hint' }, card.hint) : null, error ? h('span', { className: 'bt-inbox-error', role: 'alert' }, error) : null), h('span', { className: 'bt-inbox-actions' }, @@ -322,7 +367,8 @@ export const INBOX_CSS = ` .bt-inbox-status[data-tone=muted]{background:var(--bt-hover);color:var(--bt-ink-2)} .bt-inbox-meta{min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .bt-inbox-title{font-size:13.5px;line-height:19px;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.bt-inbox-reason,.bt-inbox-hint{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-reason,.bt-inbox-hint,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-activity{color:var(--bt-ink-3)} .bt-inbox-error{font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere} .bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} .bt-inbox-actions .bt-send{height:28px;font-size:12px} diff --git a/patches/ds-bot/files/test/inbox.test.js b/patches/ds-bot/files/test/inbox.test.js index fee31567..86d96293 100644 --- a/patches/ds-bot/files/test/inbox.test.js +++ b/patches/ds-bot/files/test/inbox.test.js @@ -256,3 +256,81 @@ test('the sidebar has one Inbox row at its top, and the dialog is an overlay', ( assert.ok(sidebar.indexOf('h(InboxRow,') < sidebar.indexOf("className: 'bt-list'")) assert.match(readFileSync(new URL('../src/client/overlays.js', import.meta.url), 'utf8'), /h\(InboxDialog,/) }) + +// #471: failed turns from the ended turns the finish/fail notifications use (#353), and a +// one-line latest activity on each card. +const endedWork = { + ...work, + ended: [ + { id: 'e1', botId: 'b2', sessionId: 's2', at: NOW - 10 * 60_000 }, + { id: 'e2', botId: 'b2', sessionId: 's2', at: NOW - 5 * 60_000, failed: 'upstream busy' }, + { id: 'e3', botId: 'b3', roomId: 'r1', at: NOW - 3 * 60_000, failed: 'quota' }, + { id: 'e4', botId: 'b1', sessionId: 's1', at: NOW - 4 * 60_000, failed: 'timeout' }, + { id: 'e5', botId: 'f1', sessionId: 'f1', at: NOW - 60_000, failed: 'friend side' }, + { id: 'e6', botId: 'b3', sessionId: 's3', at: NOW - 8 * DAY, failed: 'too old' }, + { id: 'e7', botId: 'b1', roomId: 'r2', at: NOW - 9 * 60_000, failed: 'gone' }, + { id: 'e8', botId: 'b1', roomId: 'r2', at: NOW - 2 * 60_000 }, + ], +} +const endedSessions = { + byId: { + ...sessions.byId, + r1: { updatedAt: NOW - 60_000, projectionValues: { turnOutline: [{ turn: 4, prompt: 'Ship the release notes', response: '' }] } }, + }, +} +const endedModel = inboxModel({ roster, work: endedWork, tasks, sessions: endedSessions, now: NOW }) + +test('a failed turn of my own Bot needs me while it is the latest turn in its chat and the Bot is not back at work there', () => { + const turns = endedModel.needs.filter(card => card.kind === 'turn') + // b3 in Ops waits for approval a1 and b1 runs again in its own chat; r2's failure was + // followed by a finished turn; f1 is a friend's Bot; e6 is older than 7 days. + assert.deepEqual(plain(ids(turns)), ['turn:e2']) + const [card] = turns + assert.equal(card.status, 'failed') + assert.equal(card.reason, 'upstream busy') + assert.equal(card.chat, 'b2') + assert.equal(card.title, 'Here is the weekly summary of everything that changed') + assert.equal(card.executingBot, 'b2') + // The chat whose last turn failed is not also Done. + assert.ok(!ids(endedModel.done).includes('chat:b2')) + assert.equal(needsCount(roster, endedWork, tasks), model.needs.length + 1) + // Without the Session list (the row's badge), the title says what happened. + assert.equal(inboxModel({ roster, work: endedWork, tasks, now: NOW }).needs.find(entry => entry.id === 'turn:e2').title, 'The last turn did not finish') + assert.deepEqual(plain(buttons(draw(card)).map(textOf)), ['Open']) +}) + +test('a failure no longer needs me once the Bot works or waits there again', () => { + const again = inboxModel({ roster, work: { ...endedWork, bots: { ...endedWork.bots, b2: { since: NOW - 1000 } } }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(!ids(again.needs).includes('turn:e2')) + const freed = inboxModel({ roster, work: { ...endedWork, approvals: [], rooms: {} }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(ids(freed.needs).includes('turn:e3')) + assert.equal(freed.needs.find(card => card.id === 'turn:e3').place, 'in Ops') +}) + +test('each card has a one-line latest activity that never repeats its title or reason', () => { + const byId = Object.fromEntries([...endedModel.needs, ...endedModel.progress, ...endedModel.done].map(card => [card.id, card])) + // An approval and a running turn in Ops show that chat's last line. + assert.equal(byId['approval:a1'].activity, 'Ship the release notes') + assert.equal(byId['run:b1|r1'].activity, 'Ship the release notes') + // A held message in b2's chat shows its last line; a task its newest note. + assert.equal(byId['held:b2'].activity, 'Here is the weekly summary of everything that changed') + const noted = inboxModel({ roster, work, tasks: [task('t10', 'working', { actors: [{ status: 'claimed', why: 'Reading the spec' }] })], now: NOW }) + assert.equal(noted.progress.find(card => card.id === 'task:t10').activity, 'Reading the spec') + // A running task's Bot shows the tool it runs when there is no note. + const busy = inboxModel({ roster, work: { bots: { b2: { since: NOW - 1000, tool: 'pwsh' } } }, tasks: [task('t11', 'working')], now: NOW }) + assert.match(busy.progress.find(card => card.id === 'task:t11').activity, /…$/) + // Repeats are left out: the failed turn's title is its chat's last line, the failed task's + // reason its newest note (so its chat's last line shows instead). + assert.equal(byId['turn:e2'].activity, '') + assert.equal(byId['task:t2'].activity, 'Here is the weekly summary of everything that changed') + assert.equal(inboxModel({ roster, work: {}, tasks, sessions, now: NOW }).done.find(card => card.id === 'chat:b2').activity, '') + for (const card of Object.values(byId)) { + assert.ok(card.activity !== card.title || card.activity === '', card.id) + const rows = byClass(draw(card), 'bt-inbox-activity') + assert.equal(rows.length, card.activity ? 1 : 0, card.id) + if (card.activity) { + assert.equal(rows[0].props.title, card.activity) + assert.equal(textOf(rows[0]), `Latest: ${card.activity}`) + } + } +})