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 5331337e..06dd4ffd 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 93b65abd..0172ce57 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`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | ## Invariants @@ -105,9 +105,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). ### Phone-width layout (#362, step 1) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 2f29e0ba..950795d0 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 714fc64b..6e9fadc2 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": "刚开始", @@ -1787,21 +1789,21 @@ 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") { -@@ -1583,50 +1583,236 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { - if (hidden && peer === void 0) continue; - turns.push({ turn: entry, peer, hidden }); +@@ -1573,60 +1573,246 @@ function relaysBefore(contexts, steps, step) { + break; + } } -- 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 (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]; @@ -1909,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; @@ -2388,16 +2409,10 @@ 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} -@@ -2842,239 +2842,221 @@ function installCompiledStyles(text, tag) { - timer = setTimeout(render, 120); - }; - const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { +@@ -2848,233 +2848,215 @@ function installCompiledStyles(text, tag) { + observer.observe(document.head, { childList: true }); + document.addEventListener("load", onLoad, true); + return () => { - observer.disconnect(); - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); @@ -2505,12 +2520,6 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; -+ 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); @@ -4456,7 +4465,8 @@ diff --git a/client.js b/client.js + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react8.createElement)(RemoteMessage, { @@ -4469,8 +4479,7 @@ 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) => { @@ -4868,7 +4877,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); } @@ -4973,6 +4982,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 = []; @@ -5038,6 +5060,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; @@ -5062,6 +5107,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; @@ -5071,6 +5117,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; @@ -5126,6 +5173,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 + ), @@ -5250,7 +5298,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}`) + } + } +})