From 1958157a007ae2809bf7ea82426f985f39b52074 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 10:02:48 +0800 Subject: [PATCH] Show each inbox card's turn tool-call count (#471) The v1 count board of #471 asks for one `N tool calls` line per card, for that card's own place. turns.js already folds `tool/call` events, so the count rides the existing work snapshot: the running turn carries it in work.bots / work.rooms, and an ended turn keeps its own. The inbox reads it for the card's place only, shows nothing for 0 or unknown, and never adds places together. No new polling, no new event source. Part of #471 (v1 scope of record; the three-column board stays v2). --- core/client.js | 21 +++++- core/src/client/i18n.js | 1 + core/src/client/inbox.js | 24 ++++++- core/src/host/turns.js | 14 +++- core/test/approval-notices.test.js | 36 +++++++++- core/test/group-work.test.js | 2 +- core/test/inbox.test.js | 46 +++++++++++++ docs/subsystems/core-client.md | 5 +- docs/subsystems/core-host.md | 6 +- patches/ds-bot/0001-core-fixes.patch | 86 ++++++++++++++++++++---- patches/ds-bot/0008-client-bundle.patch | 25 ++++++- patches/ds-bot/files/src/client/inbox.js | 24 ++++++- patches/ds-bot/files/test/inbox.test.js | 46 +++++++++++++ 13 files changed, 307 insertions(+), 29 deletions(-) diff --git a/core/client.js b/core/client.js index a7043ff7..fc5e426a 100644 --- a/core/client.js +++ b/core/client.js @@ -615,6 +615,7 @@ var ZH = { "Approve it in the chat": "请在对话里批准", "The last turn did not finish": "上一轮没有完成", "Latest: {text}": "最新:{text}", + "{count} tool calls": "{count} 次工具调用", "Untitled task": "未命名任务", "for {time}": "已 {time}", "just started": "刚开始", @@ -6890,6 +6891,19 @@ function latestActivity(card, { roster, work, sessions }) { 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 toolCalls(card, { roster, work }) { + const botId = card.bot?.id; + if (botId === void 0) return 0; + const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; + const running = runningIn(work, botId, roomId); + if (running !== void 0) return running.calls ?? 0; + let newest; + for (const entry of work.ended ?? []) { + if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; + if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; + } + return newest?.calls ?? 0; +} function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) { const needs = []; const progress = []; @@ -7031,7 +7045,10 @@ 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 }); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); + card.calls = toolCalls(card, { roster, work }); + } 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; @@ -7086,6 +7103,7 @@ function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { (0, import_react12.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) ), (0, import_react12.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), + card.calls > 0 ? (0, import_react12.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, card.reason ? (0, import_react12.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, card.activity ? (0, import_react12.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, card.hint ? (0, import_react12.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, @@ -7214,6 +7232,7 @@ var INBOX_CSS = ` .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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} .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} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 3030d053..efd31d22 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -556,6 +556,7 @@ const ZH = { 'Approve it in the chat': '请在对话里批准', 'The last turn did not finish': '上一轮没有完成', 'Latest: {text}': '最新:{text}', + '{count} tool calls': '{count} 次工具调用', 'Untitled task': '未命名任务', 'for {time}': '已 {time}', 'just started': '刚开始', diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index 56dfb74f..ead131f8 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -118,6 +118,23 @@ function latestActivity(card, { roster, work, sessions }) { return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' } +// The tool calls of the turn a card shows for its place (#471): the turn running there now, +// else the newest ended turn there. One place only — never added across places, and 0 +// when that turn used no tool or the place has no count. +function toolCalls(card, { roster, work }) { + const botId = card.bot?.id + if (botId === undefined) return 0 + const roomId = roster.roomsById?.[card.chat] ? card.chat : undefined + const running = runningIn(work, botId, roomId) + if (running !== undefined) return running.calls ?? 0 + let newest + for (const entry of work.ended ?? []) { + if (entry.botId !== botId || (entry.roomId ?? '') !== (roomId ?? '')) continue + if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry + } + return newest?.calls ?? 0 +} + /** * The three groups of cards, newest first in each. * `sessions` is the shell's Session list ({ byId }); `now` the current time. @@ -230,7 +247,10 @@ 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 }) + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }) + card.calls = toolCalls(card, { roster, work }) + } return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) } } @@ -277,6 +297,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h(Status, { status: card.status }), h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), + card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null, 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, @@ -385,6 +406,7 @@ export const INBOX_CSS = ` .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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} .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} diff --git a/core/src/host/turns.js b/core/src/host/turns.js index 9d2c22fc..3bdd2f0b 100644 --- a/core/src/host/turns.js +++ b/core/src/host/turns.js @@ -247,7 +247,7 @@ export function install(rt) { if (botId === undefined || (owner !== undefined && bots[botId] !== undefined)) continue const step = entry.tool ? stepOf(entry) : undefined const handed = owner ? undefined : handedFromGroup(id) - bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) } + bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) } } return bots } @@ -264,6 +264,7 @@ export function install(rt) { botId: owner.botId, since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), + ...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}), ...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}), }] } @@ -298,7 +299,7 @@ export function install(rt) { .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) => { + const noteEnded = (id, turn, info, reason, failure, calls) => { if (failure === undefined && reason?.kind !== 'completed') return const owner = rt.groupOwners.get(id) const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) @@ -312,6 +313,7 @@ export function install(rt) { botId: bot.id, ...(owner ? { roomId: owner.roomId } : { sessionId: id }), at, + ...(calls > 0 ? { calls } : {}), ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), }], at) } @@ -347,6 +349,7 @@ export function install(rt) { case 'tool/call': { const name = String(event.data?.name ?? '') startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? '')) + if (entry.turn !== undefined) entry.toolCalls = (entry.toolCalls ?? 0) + 1 // A program is as quick as the tools it runs, so its sub-calls decide. if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name) break @@ -369,6 +372,9 @@ export function install(rt) { entry.since = event.time ?? Date.now() entry.texts = [] entry.info = undefined + // The turn's tool-call count for the inbox (#471), one per tool/call event, the same + // events the activity feed folds. A program's sub-calls are not tool/call events. + entry.toolCalls = 0 // A turn turned away before its first step may end without a turn/end. entry.switched = false entry.answers = false @@ -502,6 +508,7 @@ export function install(rt) { const switched = entry.switched === true // The turn whose text answers a friend's line: a held reply is drawn on it, in place (#418). const answeredTurn = entry.turn + const toolCalls = entry.toolCalls entry.turn = undefined entry.info = undefined entry.texts = [] @@ -510,6 +517,7 @@ export function install(rt) { entry.since = undefined entry.tool = undefined entry.calls = undefined + entry.toolCalls = undefined entry.lastText = undefined entry.lastCalled = undefined entry.origin = undefined @@ -525,7 +533,7 @@ export function install(rt) { const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText) const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) - if (!retry) noteEnded(id, answeredTurn, info, reason, failure) + if (!retry) noteEnded(id, answeredTurn, info, reason, failure, toolCalls) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply diff --git a/core/test/approval-notices.test.js b/core/test/approval-notices.test.js index 424b54d4..ecd3a42e 100644 --- a/core/test/approval-notices.test.js +++ b/core/test/approval-notices.test.js @@ -203,7 +203,7 @@ test('notifications from one chat collapse into one that counts the updates unti }) // The host side: which ended turns the window hears of. -function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { +function turnsHost({ bots, rooms = {}, groupOwners = new Map(), chatOwners = new Map() } = {}) { let listener const rt = { ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, @@ -211,7 +211,7 @@ function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { isBotSession: () => true, roomOf: id => rooms[id], groupOwners, - chatOwners: new Map(), + chatOwners, botOf: id => bots[id], selfOf: id => groupOwners.get(id)?.botId ?? id, save: async () => {}, @@ -230,7 +230,7 @@ function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) emit(id, 'turn/end', { turn: n, reason }) } - return { rt, turn } + return { rt, emit, turn } } const completed = { kind: 'completed' } @@ -264,6 +264,36 @@ test('the host lists finished and failed turns of my own Bots, not stopped, aske assert.ok(rt.endedView().every(entry => entry.failed === undefined)) }) +test("the work view counts each place's tool calls for the inbox, and the ended turn keeps its own (#471)", () => { + const { rt, emit } = turnsHost({ + bots: { b: { id: 'b', name: 'Coder' } }, + rooms: { r1: { id: 'r1', name: 'Ops' } }, + groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }]]), + chatOwners: new Map([['b', 'b']]), + }) + const work = () => rt.workView().b + emit('b', 'turn/start', { turn: 1 }) + assert.equal(work().calls, undefined) + emit('b', 'tool/call', { callId: 'k1', name: 'pwsh' }) + emit('b', 'tool/call', { callId: 'k2', name: 'write' }) + assert.equal(work().calls, 2) + // A turn without a tool call has no count to show. + emit('b', 'turn/end', { turn: 1, reason: completed }) + assert.equal(work(), undefined) + assert.equal(rt.endedView()[0].calls, 2) + emit('b', 'turn/start', { turn: 2 }) + emit('b', 'turn/end', { turn: 2, reason: completed }) + assert.equal(rt.endedView()[1].calls, undefined) + // A group turn counts for that group only: it is never added to the Bot's own chat. + emit('g1', 'turn/start', { turn: 1 }) + emit('g1', 'tool/call', { callId: 'k3', name: 'pwsh' }) + assert.deepEqual(rt.roomWorkView().r1.map(entry => [entry.botId, entry.calls]), [['b', 1]]) + // The Bot's entry names the group, so no own-chat card picks that count up. + assert.deepEqual([rt.workView().b.roomId, rt.workView().b.calls], ['r1', 1]) + emit('g1', 'turn/end', { turn: 1, reason: completed }) + assert.deepEqual(rt.endedView().map(entry => [entry.roomId, entry.calls]), [[undefined, 2], [undefined, undefined], ['r1', 1]]) +}) + test('Settings → General has one switch per kind under the main one, off while the main one is off (#469)', async () => { const { readFileSync } = await import('node:fs') const settings = readFileSync(new URL('../src/client/settings.js', import.meta.url), 'utf8') diff --git a/core/test/group-work.test.js b/core/test/group-work.test.js index 948b31e5..d87be53c 100644 --- a/core/test/group-work.test.js +++ b/core/test/group-work.test.js @@ -297,7 +297,7 @@ test('an own-chat turn handed out from a group names that group in the work view await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') host.begin('c') host.emit('c', 'tool/call', { callId: 'k1', name: 'pwsh', arguments: JSON.stringify({ command: 'npm test' }) }) - assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', fromRoomId: 'r1' }) + assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', calls: 1, fromRoomId: 'r1' }) host.finish('c', 'ok') // The user's own line, and a hand-off answered to the user in the Bot's own chat, are the Bot's own. host.emit('a', 'turn/start', { turn: 1 }) diff --git a/core/test/inbox.test.js b/core/test/inbox.test.js index a46941a4..393fd72d 100644 --- a/core/test/inbox.test.js +++ b/core/test/inbox.test.js @@ -335,6 +335,52 @@ test('each card has a one-line latest activity that never repeats its title or r } }) +// #471 v1: one tool-call count per card, for that card's own place, never summed. +const countedWork = { + bots: { b1: { since: NOW - 60_000, tool: 'pwsh', calls: 3 } }, + rooms: { r1: [{ botId: 'b3', since: NOW - 30_000, calls: 2 }, { botId: 'b1', since: NOW - 60_000, calls: 5 }] }, + approvals: [ + { id: 'c1', botId: 'b1', tool: 'edit', at: NOW - 1000, answerable: false }, + { id: 'c2', botId: 'b3', roomId: 'r1', tool: 'pwsh', at: NOW - 1000, answerable: true }, + ], + ended: [ + { id: 'x2', botId: 'b2', sessionId: 's2', at: NOW - 600_000 }, + { id: 'x1', botId: 'b2', sessionId: 's2', at: NOW - 120_000, calls: 7 }, + { id: 'x3', botId: 'b2', roomId: 'r1', at: NOW - 300_000, calls: 1 }, + ], +} +const countedTasks = [task('n1', 'working'), task('n2', 'working', { ownerBotId: 'b2', origin: { roomId: 'r1' } })] +const countedModel = inboxModel({ roster, work: countedWork, tasks: countedTasks, sessions, now: NOW }) +const countedCard = id => [...countedModel.needs, ...countedModel.progress, ...countedModel.done].find(card => card.id === id) + +test("a card shows its own place's tool-call count, from the running turn there or the last ended one (#471)", () => { + // The turn running in the place now: b1's own chat (3) and b3's turn in Ops (2). + assert.equal(countedCard('approval:c1').calls, 3) + assert.equal(countedCard('approval:c2').calls, 2) + assert.equal(countedCard('run:b1|r1').calls, 5) + // No turn running there: the newest ended turn in that place, never an older or another place's. + assert.equal(countedCard('task:n1').calls, 7) + assert.equal(countedCard('task:n2').calls, 1) + assert.equal(countedCard('chat:b2').calls, 7) + // Unknown (a card with no Bot, a place with no counted turn) is 0, and nothing is summed. + assert.equal(countedCard('held:r1').calls, 0) + assert.equal(inboxModel({ roster, work: { ...countedWork, ended: [] }, tasks: countedTasks, sessions, now: NOW }).progress.find(card => card.id === 'task:n1').calls, 0) +}) + +test('a count of 0 or unknown draws no line; a counted card draws one `N tool calls` line, read-only', () => { + const callsRows = card => byClass(draw(card), 'bt-inbox-calls') + const [row] = callsRows(countedCard('approval:c1')) + assert.equal(row.props.className, 'bt-inbox-calls') + assert.equal(textOf(row), '3 tool calls') + assert.equal(textOf(callsRows(countedCard('task:n2'))[0]), '1 tool calls') + for (const card of [countedCard('held:r1'), ...inboxModel({ roster, work, tasks, sessions, now: NOW }).done]) { + assert.equal(callsRows(card).length, 0, card.id) + assert.doesNotMatch(textOf(draw(card)), /tool calls/, card.id) + } + // Still read-only: the same single Open action, on a counted card too. + assert.deepEqual(plain(buttons(draw(countedCard('approval:c1'))).map(textOf)), ['Open']) +}) + test('a question asked about work handed out from a group is a Needs you card that opens the group (#552)', () => { const asked = { ...roster, diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 4c963b2a..8b720500 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). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). 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`); failed turns and a *Latest* line per card (#471); a group's open question card (#552). 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, a *Latest* line and a per-card `N tool calls` count (#471); a group's open question card (#552). Tests: `core/test/inbox.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.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`. | @@ -114,7 +114,8 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A group's open question card is a *Needs you* card too (#552): `roster.questions[]` — a card a Bot asked about work handed out from that group (#550) — has the status **Needs your answer** (`input`, as a blocked task's question does), the question as its title, its detail as the reason, and *in * as its place; **Open** opens that group, where the card sits in the composer dock and takes the answer, so the inbox adds no second answer path. A card in a Bot's own chat, one for a removed group, and one from another member's Bot add none. - 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. - **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). +- **Tool-call count (#471):** a card whose place used at least one tool shows one muted line, `N tool calls` (`card.calls`, `bt-inbox-calls`): the count of the turn running in that place now, else of the newest ended turn there, from the work snapshot (`work.bots[id].calls`, `work.rooms[roomId][].calls`, `work.ended[].calls`, [core-host](core-host.md#tool-call-count-per-turn-471)). One place only, never added across places; a count of 0 or unknown draws no line. Nothing new is polled and the card stays read-only. i18n key `{count} tool calls`. +- Out of scope so far (#471): the three-column board with a List / Board toggle (v2), 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 307ada61..337fe9a0 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -127,6 +127,10 @@ credentials and team mode remain separate work under #55/#198. `work.bots[id]` (`turns.js` `workView`) carries `step`: what the newest running call acts on, from its arguments (`command`, `cmd`, `pattern`, `query`, `url`, `file_path`, `path`, else `description`), whitespace collapsed and capped at 80 characters. It is in the owner-only Bot view (`authz.js` `ownedLive` picks the actor's own Bots), not in `work.rooms`, which other members of a shared group read. It goes with the call's result and the turn's end. Covered by `core/test/group-status.test.js`. +### Tool-call count per turn (#471) + +`turns.js` counts a turn's `tool/call` events (the same events the activity feed folds; a program's sub-calls are not counted) and the count rides the work view: the running turn's count is `calls` on `work.bots[id]` and on its entry in `work.rooms[roomId]`, only while it is above 0; a turn that ended keeps its own count as `calls` on its `endedView()` entry. One place only — the Agent inbox reads the count for a card's own place (that Bot's own chat, or that one group) from those fields, never summed across places and shown as nothing for 0 or unknown. Covered by `core/test/approval-notices.test.js` and the inbox model's tests. + ### Work handed out from a group, in the work view (#553) An own-chat turn that runs work a group member handed out with `message_bot` (`turns.js` `handedFromGroup`, the rule below that #550's group questions also use, exposed as `rt.handedFromGroup`) carries `fromRoomId` in `work.bots[id]`, so that group can show the work as its own while other rooms and the Bot's unrelated own-chat work stay apart. Like `step`, it is only in the owner-only Bot view. Covered by `core/test/group-work.test.js`. @@ -175,7 +179,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Ended turns for 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), and `notify: { approval | finished | failed: boolean }` (#469: merged into `prefs.notify`, only the `false` ones kept, removed when empty). 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, calls? }` (`calls` is that turn's tool-call count, #471; left out for 0). 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), and `notify: { approval | finished | failed: boolean }` (#469: merged into `prefs.notify`, only the `false` ones kept, removed when empty). 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 249eb74c..c79b69b3 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -735,7 +735,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,36 @@ const ZH = { +@@ -360,9 +360,37 @@ const ZH = { 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -764,6 +764,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Approve it in the chat': '请在对话里批准', + 'The last turn did not finish': '上一轮没有完成', + 'Latest: {text}': '最新:{text}', ++ '{count} tool calls': '{count} 次工具调用', + 'Untitled task': '未命名任务', + 'for {time}': '已 {time}', + 'just started': '刚开始', @@ -3742,11 +3743,19 @@ diff --git a/src/host/turns.js b/src/host/turns.js - bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(owner ? { roomId: owner.roomId } : {}) } + const step = entry.tool ? stepOf(entry) : undefined + const handed = owner ? undefined : handedFromGroup(id) -+ bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) } ++ bots[botId] = { since: entry.since, ...(entry.tool ? { tool: entry.tool } : {}), ...(step ? { step } : {}), ...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}), ...(owner ? { roomId: owner.roomId } : {}), ...(handed ? { fromRoomId: handed.room.id } : {}) } } return bots } -@@ -238,6 +238,44 @@ export function install(rt) { +@@ -221,6 +221,7 @@ export function install(rt) { + botId: owner.botId, + since: entry.since, + ...(entry.tool ? { tool: entry.tool } : {}), ++ ...(entry.toolCalls > 0 ? { calls: entry.toolCalls } : {}), + ...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}), + }] + } +@@ -238,6 +238,45 @@ export function install(rt) { return { ...(text ? { text: text.trim().slice(0, 300) } : {}), ...(code ? { code: code.trim().slice(0, 400) } : {}) } } @@ -3767,7 +3776,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + .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) => { ++ const noteEnded = (id, turn, info, reason, failure, calls) => { + if (failure === undefined && reason?.kind !== 'completed') return + const owner = rt.groupOwners.get(id) + const bot = rt.botOf(owner?.botId ?? rt.selfOf(id)) @@ -3781,6 +3790,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js + botId: bot.id, + ...(owner ? { roomId: owner.roomId } : { sessionId: id }), + at, ++ ...(calls > 0 ? { calls } : {}), + ...(failure ? { failed: (failure.message || failure.code).slice(0, 160) } : {}), + }], at) + } @@ -3791,7 +3801,23 @@ diff --git a/src/host/turns.js b/src/host/turns.js // Where a Session speaks, for the diagnostics log. const placeOf = (id) => { const room = rt.roomOf(rt.groupOwners.get(id)?.roomId) ?? rt.roomOf(id) -@@ -293,6 +293,9 @@ export function install(rt) { +@@ -266,6 +266,7 @@ export function install(rt) { + case 'tool/call': { + const name = String(event.data?.name ?? '') + startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? '')) ++ if (entry.turn !== undefined) entry.toolCalls = (entry.toolCalls ?? 0) + 1 + // A program is as quick as the tools it runs, so its sub-calls decide. + if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name) + break +@@ -288,11 +288,17 @@ export function install(rt) { + entry.since = event.time ?? Date.now() + entry.texts = [] + entry.info = undefined ++ // The turn's tool-call count for the inbox (#471), one per tool/call event, the same ++ // events the activity feed folds. A program's sub-calls are not tool/call events. ++ entry.toolCalls = 0 + // A turn turned away before its first step may end without a turn/end. + entry.switched = false entry.answers = false entry.opened = false entry.joined = [] @@ -3858,7 +3884,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js delete state.questions[id] void rt.save().catch(() => {}) } -@@ -386,11 +386,16 @@ export function install(rt) { +@@ -386,11 +386,17 @@ export function install(rt) { case 'turn/end': { setActivity(id, undefined) const info = entry.info @@ -3872,11 +3898,15 @@ diff --git a/src/host/turns.js b/src/host/turns.js const switched = entry.switched === true + // The turn whose text answers a friend's line: a held reply is drawn on it, in place (#418). + const answeredTurn = entry.turn ++ const toolCalls = entry.toolCalls entry.turn = undefined entry.info = undefined entry.texts = [] -@@ -401,9 +401,11 @@ export function install(rt) { +@@ -399,11 +399,14 @@ export function install(rt) { + entry.since = undefined + entry.tool = undefined entry.calls = undefined ++ entry.toolCalls = undefined entry.lastText = undefined entry.lastCalled = undefined + entry.origin = undefined @@ -3893,7 +3923,7 @@ diff --git a/src/host/turns.js b/src/host/turns.js const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId)) - if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) + if (retry) void retryWrittenCall(id, info, origin).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error)) -+ if (!retry) noteEnded(id, answeredTurn, info, reason, failure) ++ if (!retry) noteEnded(id, answeredTurn, info, reason, failure, toolCalls) const replyText = () => { const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim() return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply @@ -4060,7 +4090,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js page.shown[0].onclick() assert.deepEqual([page.opened, page.shown[0].closed], [['r1'], true]) }) -@@ -62,3 +62,207 @@ test('no notification for the conversation on screen, for approvals older than t +@@ -62,3 +62,237 @@ 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) }) @@ -4201,7 +4231,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js +}) + +// The host side: which ended turns the window hears of. -+function turnsHost({ bots, rooms = {}, groupOwners = new Map() } = {}) { ++function turnsHost({ bots, rooms = {}, groupOwners = new Map(), chatOwners = new Map() } = {}) { + let listener + const rt = { + ctx: { on: (name, fn) => { if (name === 'session/event') listener = fn } }, @@ -4209,7 +4239,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + isBotSession: () => true, + roomOf: id => rooms[id], + groupOwners, -+ chatOwners: new Map(), ++ chatOwners, + botOf: id => bots[id], + selfOf: id => groupOwners.get(id)?.botId ?? id, + save: async () => {}, @@ -4228,7 +4258,7 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + if (source) emit(id, 'user/message', { content: [{ type: 'text', text: 'hi' }], source }) + emit(id, 'turn/end', { turn: n, reason }) + } -+ return { rt, turn } ++ return { rt, emit, turn } +} + +const completed = { kind: 'completed' } @@ -4262,6 +4292,36 @@ diff --git a/test/approval-notices.test.js b/test/approval-notices.test.js + assert.ok(rt.endedView().every(entry => entry.failed === undefined)) +}) + ++test("the work view counts each place's tool calls for the inbox, and the ended turn keeps its own (#471)", () => { ++ const { rt, emit } = turnsHost({ ++ bots: { b: { id: 'b', name: 'Coder' } }, ++ rooms: { r1: { id: 'r1', name: 'Ops' } }, ++ groupOwners: new Map([['g1', { botId: 'b', roomId: 'r1' }]]), ++ chatOwners: new Map([['b', 'b']]), ++ }) ++ const work = () => rt.workView().b ++ emit('b', 'turn/start', { turn: 1 }) ++ assert.equal(work().calls, undefined) ++ emit('b', 'tool/call', { callId: 'k1', name: 'pwsh' }) ++ emit('b', 'tool/call', { callId: 'k2', name: 'write' }) ++ assert.equal(work().calls, 2) ++ // A turn without a tool call has no count to show. ++ emit('b', 'turn/end', { turn: 1, reason: completed }) ++ assert.equal(work(), undefined) ++ assert.equal(rt.endedView()[0].calls, 2) ++ emit('b', 'turn/start', { turn: 2 }) ++ emit('b', 'turn/end', { turn: 2, reason: completed }) ++ assert.equal(rt.endedView()[1].calls, undefined) ++ // A group turn counts for that group only: it is never added to the Bot's own chat. ++ emit('g1', 'turn/start', { turn: 1 }) ++ emit('g1', 'tool/call', { callId: 'k3', name: 'pwsh' }) ++ assert.deepEqual(rt.roomWorkView().r1.map(entry => [entry.botId, entry.calls]), [['b', 1]]) ++ // The Bot's entry names the group, so no own-chat card picks that count up. ++ assert.deepEqual([rt.workView().b.roomId, rt.workView().b.calls], ['r1', 1]) ++ emit('g1', 'turn/end', { turn: 1, reason: completed }) ++ assert.deepEqual(rt.endedView().map(entry => [entry.roomId, entry.calls]), [[undefined, 2], [undefined, undefined], ['r1', 1]]) ++}) ++ +test('Settings → General has one switch per kind under the main one, off while the main one is off (#469)', async () => { + const { readFileSync } = await import('node:fs') + const settings = readFileSync(new URL('../src/client/settings.js', import.meta.url), 'utf8') @@ -5270,7 +5330,7 @@ diff --git a/test/group-work.test.js b/test/group-work.test.js + await host.tool('message_bot', { to: 'Tester', message: '把测试跑一遍' }, 'gb') + host.begin('c') + host.emit('c', 'tool/call', { callId: 'k1', name: 'pwsh', arguments: JSON.stringify({ command: 'npm test' }) }) -+ assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', fromRoomId: 'r1' }) ++ assert.deepEqual(plain((await bots()).c), { since: (await bots()).c.since, tool: 'pwsh', step: 'npm test', calls: 1, fromRoomId: 'r1' }) + host.finish('c', 'ok') + // The user's own line, and a hand-off answered to the user in the Bot's own chat, are the Bot's own. + host.emit('a', 'turn/start', { turn: 1 }) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f3cac826..263f9a52 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,35 @@ var ZH = { +@@ -420,8 +420,36 @@ var ZH = { "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -361,6 +361,7 @@ diff --git a/client.js b/client.js + "Approve it in the chat": "请在对话里批准", + "The last turn did not finish": "上一轮没有完成", + "Latest: {text}": "最新:{text}", ++ "{count} tool calls": "{count} 次工具调用", + "Untitled task": "未命名任务", + "for {time}": "已 {time}", + "just started": "刚开始", @@ -4829,7 +4830,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4624,156 +4624,864 @@ function useRowEntrances(replacing) { +@@ -4624,156 +4624,882 @@ function useRowEntrances(replacing) { const observer = new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(scan); }); @@ -5357,6 +5358,19 @@ diff --git a/client.js b/client.js + 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 toolCalls(card, { roster, work }) { ++ const botId = card.bot?.id; ++ if (botId === void 0) return 0; ++ const roomId = roster.roomsById?.[card.chat] ? card.chat : void 0; ++ const running = runningIn(work, botId, roomId); ++ if (running !== void 0) return running.calls ?? 0; ++ let newest; ++ for (const entry of work.ended ?? []) { ++ if (entry.botId !== botId || (entry.roomId ?? "") !== (roomId ?? "")) continue; ++ if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry; ++ } ++ return newest?.calls ?? 0; ++} +function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() }) { + const needs = []; + const progress = []; @@ -5530,7 +5544,10 @@ diff --git a/client.js b/client.js - return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); -+ for (const card of [...needs, ...progress, ...done]) card.activity = latestActivity(card, { roster, work, sessions }); ++ for (const card of [...needs, ...progress, ...done]) { ++ card.activity = latestActivity(card, { roster, work, sessions }); ++ card.calls = toolCalls(card, { roster, work }); ++ } + 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; @@ -5594,6 +5611,7 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) + ), + (0, import_react12.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), ++ card.calls > 0 ? (0, import_react12.createElement)("span", { className: "bt-inbox-calls" }, t("{count} tool calls", { count: card.calls })) : null, + card.reason ? (0, import_react12.createElement)("span", { className: "bt-inbox-reason", title: card.reason }, card.reason) : null, + card.activity ? (0, import_react12.createElement)("span", { className: "bt-inbox-activity", title: card.activity }, t("Latest: {text}", { text: card.activity })) : null, + card.hint ? (0, import_react12.createElement)("span", { className: "bt-inbox-hint" }, card.hint) : null, @@ -5732,6 +5750,7 @@ diff --git a/client.js b/client.js +.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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} +.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} diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index 56dfb74f..ead131f8 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -118,6 +118,23 @@ function latestActivity(card, { roster, work, sessions }) { return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' } +// The tool calls of the turn a card shows for its place (#471): the turn running there now, +// else the newest ended turn there. One place only — never added across places, and 0 +// when that turn used no tool or the place has no count. +function toolCalls(card, { roster, work }) { + const botId = card.bot?.id + if (botId === undefined) return 0 + const roomId = roster.roomsById?.[card.chat] ? card.chat : undefined + const running = runningIn(work, botId, roomId) + if (running !== undefined) return running.calls ?? 0 + let newest + for (const entry of work.ended ?? []) { + if (entry.botId !== botId || (entry.roomId ?? '') !== (roomId ?? '')) continue + if ((entry.at ?? 0) >= (newest?.at ?? -Infinity)) newest = entry + } + return newest?.calls ?? 0 +} + /** * The three groups of cards, newest first in each. * `sessions` is the shell's Session list ({ byId }); `now` the current time. @@ -230,7 +247,10 @@ 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 }) + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }) + card.calls = toolCalls(card, { roster, work }) + } return { needs: needs.sort(newest), progress: progress.sort(newest), done: done.sort(newest).slice(0, DONE_MAX) } } @@ -277,6 +297,7 @@ export function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) h(Status, { status: card.status }), h('span', { className: 'bt-inbox-meta', title: meta }, meta)), h('span', { className: 'bt-inbox-title', title: card.title }, card.title), + card.calls > 0 ? h('span', { className: 'bt-inbox-calls' }, t('{count} tool calls', { count: card.calls })) : null, 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, @@ -385,6 +406,7 @@ export const INBOX_CSS = ` .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,.bt-inbox-activity{font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-inbox-calls{font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums} .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} diff --git a/patches/ds-bot/files/test/inbox.test.js b/patches/ds-bot/files/test/inbox.test.js index a46941a4..393fd72d 100644 --- a/patches/ds-bot/files/test/inbox.test.js +++ b/patches/ds-bot/files/test/inbox.test.js @@ -335,6 +335,52 @@ test('each card has a one-line latest activity that never repeats its title or r } }) +// #471 v1: one tool-call count per card, for that card's own place, never summed. +const countedWork = { + bots: { b1: { since: NOW - 60_000, tool: 'pwsh', calls: 3 } }, + rooms: { r1: [{ botId: 'b3', since: NOW - 30_000, calls: 2 }, { botId: 'b1', since: NOW - 60_000, calls: 5 }] }, + approvals: [ + { id: 'c1', botId: 'b1', tool: 'edit', at: NOW - 1000, answerable: false }, + { id: 'c2', botId: 'b3', roomId: 'r1', tool: 'pwsh', at: NOW - 1000, answerable: true }, + ], + ended: [ + { id: 'x2', botId: 'b2', sessionId: 's2', at: NOW - 600_000 }, + { id: 'x1', botId: 'b2', sessionId: 's2', at: NOW - 120_000, calls: 7 }, + { id: 'x3', botId: 'b2', roomId: 'r1', at: NOW - 300_000, calls: 1 }, + ], +} +const countedTasks = [task('n1', 'working'), task('n2', 'working', { ownerBotId: 'b2', origin: { roomId: 'r1' } })] +const countedModel = inboxModel({ roster, work: countedWork, tasks: countedTasks, sessions, now: NOW }) +const countedCard = id => [...countedModel.needs, ...countedModel.progress, ...countedModel.done].find(card => card.id === id) + +test("a card shows its own place's tool-call count, from the running turn there or the last ended one (#471)", () => { + // The turn running in the place now: b1's own chat (3) and b3's turn in Ops (2). + assert.equal(countedCard('approval:c1').calls, 3) + assert.equal(countedCard('approval:c2').calls, 2) + assert.equal(countedCard('run:b1|r1').calls, 5) + // No turn running there: the newest ended turn in that place, never an older or another place's. + assert.equal(countedCard('task:n1').calls, 7) + assert.equal(countedCard('task:n2').calls, 1) + assert.equal(countedCard('chat:b2').calls, 7) + // Unknown (a card with no Bot, a place with no counted turn) is 0, and nothing is summed. + assert.equal(countedCard('held:r1').calls, 0) + assert.equal(inboxModel({ roster, work: { ...countedWork, ended: [] }, tasks: countedTasks, sessions, now: NOW }).progress.find(card => card.id === 'task:n1').calls, 0) +}) + +test('a count of 0 or unknown draws no line; a counted card draws one `N tool calls` line, read-only', () => { + const callsRows = card => byClass(draw(card), 'bt-inbox-calls') + const [row] = callsRows(countedCard('approval:c1')) + assert.equal(row.props.className, 'bt-inbox-calls') + assert.equal(textOf(row), '3 tool calls') + assert.equal(textOf(callsRows(countedCard('task:n2'))[0]), '1 tool calls') + for (const card of [countedCard('held:r1'), ...inboxModel({ roster, work, tasks, sessions, now: NOW }).done]) { + assert.equal(callsRows(card).length, 0, card.id) + assert.doesNotMatch(textOf(draw(card)), /tool calls/, card.id) + } + // Still read-only: the same single Open action, on a counted card too. + assert.deepEqual(plain(buttons(draw(countedCard('approval:c1'))).map(textOf)), ['Open']) +}) + test('a question asked about work handed out from a group is a Needs you card that opens the group (#552)', () => { const asked = { ...roster,