From 8645a1de6b643e02cce3533bc42f579a4b257330 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 03:51:42 +0800 Subject: [PATCH 1/3] Add the task audit projection and count a rework chain once rt.taskAudit(id) renders the handoff, evidence, reworkOf chain and current lease from the typed rows already on the card; the Project board carries the same text as one audit field and the client shows it in the existing card detail. rt.taskCounters folds an accepted rework child into its accepted parent, so a rejection -> rework -> accept cycle is two records and one delivery. The strict release DAG test pins build -> test -> publish order, the refusal of an unproduced required input, and a restart that adds no second downstream task. Docs updated (core-host.md, workflow.md); client.js and patches regenerated from source. --- changelog.d/1730-task-audit.added.md | 1 + core/client.js | 12 +- core/src/client/project-board.js | 9 +- core/src/host/project-board.js | 5 + core/src/host/tasks.js | 56 +- core/test/task-audit.test.js | 169 ++++ docs/subsystems/core-host.md | 4 +- docs/subsystems/workflow.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 782 +++++++++--------- .../ds-bot/0017-task-pool-role-queues.patch | 66 +- .../ds-bot/files/src/client/project-board.js | 9 +- .../ds-bot/files/src/host/project-board.js | 5 + patches/ds-bot/files/test/task-audit.test.js | 169 ++++ .../workflow/test/strict-release-dag.test.js | 76 ++ 14 files changed, 963 insertions(+), 402 deletions(-) create mode 100644 changelog.d/1730-task-audit.added.md create mode 100644 core/test/task-audit.test.js create mode 100644 patches/ds-bot/files/test/task-audit.test.js create mode 100644 plugins/workflow/test/strict-release-dag.test.js diff --git a/changelog.d/1730-task-audit.added.md b/changelog.d/1730-task-audit.added.md new file mode 100644 index 000000000..8a59ca407 --- /dev/null +++ b/changelog.d/1730-task-audit.added.md @@ -0,0 +1 @@ +A Project board card now carries an `audit` block: the handoff, the evidence, the rework chain and the current lease rendered from the task record itself, with no new endpoint or page. A rework cycle is counted as one accepted delivery instead of two, so the counters no longer double after a rejection. diff --git a/core/client.js b/core/client.js index be80676aa..a8bc2c415 100644 --- a/core/client.js +++ b/core/client.js @@ -19621,6 +19621,8 @@ var PROJECT_BOARD_CSS = ` .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} .bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} .bt-project-row[data-column="done"]{opacity:.7} +.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} @@ -19657,6 +19659,7 @@ function projectBoardView({ project, rows, canManage, tasks } = {}) { nodeId: text(row.nodeId) || null, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, + audit: text(row.audit) || null, board: boardOfRow(row, task) }; }) @@ -19695,7 +19698,14 @@ function ProjectBoardRows({ view, onOpen }) { } }, (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), - (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), + (0, import_react46.createElement)( + "span", + { className: "bt-project-title-wrap" }, + (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), + // The audit text the row already carries (#1730): shown inside the card detail, never a + // second read. A row without one renders exactly as before. + row.audit === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-audit" }, row.audit) + ), row.priority === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-priority" }, row.priority), row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) ); diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index f2e1aa941..0187f8711 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -11,6 +11,8 @@ export const PROJECT_BOARD_CSS = ` .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} .bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} .bt-project-row[data-column="done"]{opacity:.7} +.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} @@ -64,6 +66,7 @@ export function projectBoardView({ project, rows, canManage, tasks } = {}) { nodeId: text(row.nodeId) || null, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, + audit: text(row.audit) || null, board: boardOfRow(row, task), } }), @@ -99,7 +102,11 @@ function ProjectBoardRows({ view, onOpen }) { onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, }, h('span', { className: 'bt-project-column' }, t(row.column)), - h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + h('span', { className: 'bt-project-title-wrap' }, + h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + // The audit text the row already carries (#1730): shown inside the card detail, never a + // second read. A row without one renders exactly as before. + row.audit === null ? null : h('span', { className: 'bt-project-audit' }, row.audit)), row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority), row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)) }))) diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index f2892a425..808b687da 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -54,6 +54,7 @@ export function install(rt) { : runProject?.id === id ? 'workflow' : null if (source === null) continue const fromRun = ref !== undefined && (runProject === null || runProject.id === id) + const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' rows.push({ id: task.id, source, @@ -68,6 +69,10 @@ export function install(rt) { ...(task.handoff && typeof task.handoff === 'object' ? { handoff: task.handoff } : {}), ...(Array.isArray(task.evidence) ? { evidence: task.evidence } : {}), ...(task.lease && typeof task.lease === 'object' ? { lease: task.lease } : {}), + // The same projection the task store renders (#1730): one text block a card detail can + // show, with nothing written back. A pre-slice card answers the empty string and the row + // carries no `audit` key at all. + ...(audit !== '' ? { audit } : {}), ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 9828d4e9f..d9d77ec4d 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -521,7 +521,14 @@ export function install(rt) { const runs = {} const wanted = oneLine(query?.role ?? '', ROLE_MAX) const wantedRun = query?.run === undefined || query?.run === null ? '' : String(query.run) - for (const task of Object.values(store())) { + const all = Object.values(store()) + // A rework chain is one accepted delivery (#1730): an accepted parent and the `rework` child + // that resolved it are two records of the same delivery. The child still counts in + // `accepted`; `deliveries` counts the chain once, at its accepted parent. A child whose + // parent never landed is a delivery of its own — there is no chain to fold it into. + const acceptedIds = new Set(all.filter(task => task?.status === 'accepted').map(task => task.id)) + const countsAsDelivery = task => task.taskClass !== 'rework' || !acceptedIds.has(task.reworkOf) + for (const task of all) { const role = roleOf(task) const run = String(task?.origin?.runId ?? task?.runId ?? '') if (wanted !== '' && role !== wanted) continue @@ -530,18 +537,61 @@ export function install(rt) { const row = bucket(roles, role || 'unassigned') if (row[task.status] !== undefined) row[task.status] += 1 if (task.status === 'accepted') { - row.deliveries += 1 + if (countsAsDelivery(task)) row.deliveries += 1 const lease = leaseOf(task) if (lease !== undefined && Number.isFinite(lease.takenAt)) row.workerSeconds += Math.max(0, Math.round((task.updatedAt - lease.takenAt) / 1000)) } if (run !== '') { const runRow = bucket(runs, run) if (runRow[task.status] !== undefined) runRow[task.status] += 1 - if (task.status === 'accepted') runRow.deliveries += 1 + if (task.status === 'accepted' && countsAsDelivery(task)) runRow.deliveries += 1 } } return { roles, runs } } + // One audit block from the typed rows (#1730). A projection, not a store: every line is read + // from fields the write path already validated, so rendering the same rows twice is the same + // text. A record stored before the slice carries none of the fields and answers the empty + // string — an audit of nothing is not an error. + const isoStamp = at => (Number.isFinite(at) ? new Date(at).toISOString() : '') + const auditLine = (task) => { + const lines = [] + const handoff = task.handoff !== null && typeof task.handoff === 'object' ? task.handoff : undefined + if (handoff !== undefined && handoff.from && handoff.to) { + const reason = typeof handoff.reason === 'string' && handoff.reason !== '' ? `: ${handoff.reason}` : '' + lines.push(`handoff ${handoff.from} -> ${handoff.to}${reason} @ ${isoStamp(handoff.at)}`) + } + if (Array.isArray(task.evidence)) { + for (const entry of task.evidence) { + if (entry === null || typeof entry !== 'object') continue + const parts = [] + if (entry.sha) parts.push(String(entry.sha)) + if (typeof entry.pr === 'number') parts.push(`pr #${entry.pr}`) + if (entry.check) parts.push(String(entry.check)) + const at = isoStamp(entry.at) + if (at !== '') parts.push(`@ ${at}`) + if (parts.length > 0) lines.push(`evidence ${parts.join(' ')}`) + } + } + const chain = [] + const seen = new Set() + let cursor = task + while (cursor !== undefined && cursor !== null && !seen.has(cursor.id)) { + seen.add(cursor.id) + chain.push(cursor.id) + cursor = typeof cursor.reworkOf === 'string' && cursor.reworkOf !== '' ? find(cursor.reworkOf) : undefined + } + if (chain.length > 1) lines.push(`rework chain ${chain.reverse().join(' -> ')}`) + const lease = leaseOf(task) + if (lease !== undefined) { + lines.push(`lease ${holderName(lease.holder)} @ ${isoStamp(lease.takenAt)} -> ${isoStamp(lease.expiresAt)}`) + } + return lines + } + rt.taskAudit = function taskAudit(id) { + const task = find(String(id ?? '')) + return task === undefined ? '' : auditLine(task).join('\n') + } // The workflow plugin reads a card's lease through the host (#1729), never a copy of its own. rt.taskLease = function taskLease(id) { const task = find(String(id ?? '')) diff --git a/core/test/task-audit.test.js b/core/test/task-audit.test.js new file mode 100644 index 000000000..804ad32a9 --- /dev/null +++ b/core/test/task-audit.test.js @@ -0,0 +1,169 @@ +// The audit projection and the rework delivery count (#1730). The audit text is read from the +// typed rows already on the card — handoff, evidence, reworkOf, lease — and rendered twice from +// the same rows identically. A pre-slice record answers the empty string, not a throw. A rework +// chain counts one accepted delivery across two records and a replay cannot double it. + +// The typed Task/Handoff contract (#1720): five additive fields on the one task record — +// `taskClass`, `reworkOf`, `handoff`, `evidence`, `lease` — validated on write and read by the +// Project board from the same row. The rework child is a card linked by `reworkOf` (never +// `parentId`), a lease returns an abandoned queued card to the queue, and a record stored before +// the slice still reads back and renders. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { install as installProjectBoard } from '../src/host/project-board.js' +import { formatRoomBoard } from '../src/host/room-context.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + d2: { id: 'd2', name: 'Dev2', role: 'dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + r1: { id: 'r1', name: 'Rev1', role: 'Reviewer', sessionId: 'r1', createdAt: 4, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd1', 'd2', 'r1'], admin: 'a', mode: 'everyone', named: true, + sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'member', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd1', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd2', role: 'member', displayName: 'Dev2', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'r1', role: 'member', displayName: 'Rev1', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: {}, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function boot(home) { + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const inbox = { + nextStep: [], nextTurn: [], + remove(id) { const keep = message => message.id !== id; this.nextTurn = this.nextTurn.filter(keep); this.nextStep = this.nextStep.filter(keep); return true }, + } + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls: [], status: 'idle', inbox, + followup(message) { this.calls.push(message); this.inbox.nextTurn.push(message) }, + steer() {}, inject() {}, cancel() {}, async whenIdle() {}, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 'made-1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } }) + const idOf = (body, title) => { + const line = body.split('\n').find(item => item.startsWith('- ') && item.includes(title)) + const id = line?.match(/task-[0-9a-f]+/)?.[0] + assert.ok(id, body) + return id + } + const create = async (spec = {}) => { + const body = await track(spec.bot ?? 'd1', { tasks: [{ group: 'War', ...spec }] }) + return { body, id: idOf(body, spec.title) } + } + return { ctx, rt, track, idOf, create, home, agents } +} + +async function start(t, seed = team()) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-contract-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const app = await boot(home) + app.stop = async () => rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + t.after(async () => { + try { await app.ctx.dispose() } catch {} + await app.stop() + }) + return app +} + +// A restart is the same home and a fresh runtime: the state on disk is what the new process reads. +async function restart(app) { + await app.ctx.dispose() + return boot(app.home) +} + +const childrenOf = (app, originalId) => Object.values(app.rt.state.tasks).filter(task => task.reworkOf === originalId) +const byActor = (task, why) => (task.actors ?? []).filter(entry => entry.by === why) + +test('the audit renders one identical text block from the same rows (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ + title: 'Audit the rework', + handoff: { from: 'Lead', to: 'Dev', at: 1_700_000_000_000, reason: 'needs the test' }, + }) + app.rt.state.tasks[id].evidence = [ + { sha: 'a'.repeat(40), pr: 1730, check: 'tests-ok', at: 1_700_000_100_000 }, + ] + const child = app.rt.state.tasks[id] + const audit = app.rt.taskAudit(id) + assert.match(audit, /^handoff Lead -> Dev: needs the test @ 2023-11-14T22:13:20.000Z$/m) + assert.match(audit, /^evidence a{40} pr #1730 tests-ok @ 2023-11-14T22:15:00.000Z$/m) + assert.equal(audit, app.rt.taskAudit(id), 'the same rows render the same text') + assert.equal(audit, app.rt.taskAudit(child.id), 'the store is read, not consumed') +}) + +test('the audit names the rework chain and the current lease window (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Chain me' }) + app.rt.state.tasks[id].lease = { holder: 'd1', takenAt: 1_700_000_000_000, expiresAt: 1_700_001_800_000 } + const childId = 'task-rework1' + app.rt.state.tasks[childId] = { + id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id, + ownerBotId: 'd1', createdAt: 2, updatedAt: 2, + } + const audit = app.rt.taskAudit(childId) + assert.match(audit, new RegExp('^rework chain ' + id + ' -> ' + childId + '$', 'm')) + assert.match(app.rt.taskAudit(id), /^lease Dev1 @ 2023-11-14T22:13:20.000Z -> 2023-11-14T22:43:20.000Z$/m) +}) + +test('a pre-slice record renders an empty audit, not a throw (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Old card' }) + delete app.rt.state.tasks[id].taskClass + assert.equal(app.rt.taskAudit(id), '') + assert.equal(app.rt.taskAudit('task-nope'), '') +}) + +test('a rework chain counts one accepted delivery and a replay doubles nothing (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Ship once' }) + const childId = 'task-rework2' + app.rt.state.tasks[id].status = 'accepted' + app.rt.state.tasks[id].ownerBotId = 'd1' + app.rt.state.tasks[childId] = { + id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id, + ownerBotId: 'd1', createdAt: 2, updatedAt: 2, + } + const first = app.rt.taskCounters() + assert.equal(first.roles.Dev.accepted, 2, 'two records') + assert.equal(first.roles.Dev.deliveries, 1, 'one accepted delivery for the chain') + // A replay writes the same rows again; the count is a read, so it cannot double. + app.rt.state.tasks[id].status = 'accepted' + app.rt.state.tasks[childId].status = 'accepted' + assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1) + // A rework whose parent never landed is still a delivery of its own. + delete app.rt.state.tasks[id] + assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 71b59d186..268f530ac 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -592,5 +592,7 @@ Stage 3.2 of the task pool. One queue per role, one worker per claim, one owner - **Per-role cap.** `ROLE_QUEUE_CAP` (8) is the default ceiling for one role's queue in one group. A graph states its own through `cap` on the enqueued item, kept on the card as `queueCap`; a re-offer keeps the card's id and is never counted against itself. An enqueue over the ceiling is refused `queue-full` with a sentence naming the role and the cap (`the Dev queue already holds 8 items, its cap is 8`), so the caller knows which queue is full rather than seeing a silent null. - **Isolated workers.** A claim creates a worker (`worker-`, kept in `tasks.js`'s own map, capped at 64, oldest dropped), and the card names it in `worker`. `rt.taskWorkerState(id)` is the only read: an unknown id answers `undefined`, so a second claim cannot see the first's `files` or `vars`. `requeue` and an expired lease drop the worker, so the next claim starts clean. - **One owner for an expired lease.** `expireLeases` reads the lease once, clears it, then returns a queued card to its role queue — a second read in the same tick finds no lease and answers 0. The recovery is written to the card's `evidence` (`lease expired (held by )`) within the existing eight-entry cap, the dead worker is dropped, and a card that is not a queued item only loses the dead lease. A live lease is untouched, so a restart with a live lease is a no-op; a claim on a live lease is refused `lease-held` naming the holder. `rt.taskExpireLeases` exposes the same read, and `rt.taskLease(id)` gives `{ holder, takenAt, expiresAt, live, heldBy }`; the workflow plugin reads the lease through `fb.tasks.lease(id)` and never keeps a copy. -- **Measured deliveries.** `rt.taskCounters({ role?, run? })` counts `queued`, `claimed`, `working`, `blocked`, `accepted`, `failed`, `cancelled` per role and per run id from the rows, plus `deliveries` (accepted cards) and `workerSeconds` (accepted cards with a lease, measured from `takenAt` to the card's last update). `fb.tasks.counters(query)` is the plugin seam. No metric store is added. +- **Measured deliveries.** `rt.taskCounters({ role?, run? })` counts `queued`, `claimed`, `working`, `blocked`, `accepted`, `failed`, `cancelled` per role and per run id from the rows, plus `deliveries` and `workerSeconds` (accepted cards with a lease, measured from `takenAt` to the card's last update). A rework chain is one accepted delivery (#1730): an accepted `rework` child whose `reworkOf` parent is also accepted folds into the parent's delivery, so a rejection→rework→accept cycle is two records and one delivery; a child whose parent never landed stays a delivery of its own, and a replay writes no rows so the count cannot double. `fb.tasks.counters(query)` is the plugin seam. No metric store is added. + +**Audit projection (#1730).** `rt.taskAudit(id)` renders one text block from the typed fields already on the card — `handoff` from→to/reason/at, each `evidence` entry (sha, pr, check, at), the `reworkOf` chain oldest→newest, and the current lease holder with its `takenAt`→`expiresAt` window. It is a read: nothing is written, minted or cached, so the same rows render the same text twice. A record stored before the slice carries none of the fields and answers the empty string, not a throw. The Project board puts the same text on its row as `audit` (only when non-empty) and the client shows it inside the existing card detail — no new endpoint and no new page. Tests: `core/test/task-audit.test.js`. - Tests: `core/test/task-queue-lease.test.js` — the cap refusal names role and cap, a re-offer keeps its id, two concurrent claims share no worker state, two expired reads requeue once and record the holder, a live lease refuses a claim naming the holder, a restart with a live lease changes nothing, and the counters read from the rows. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 69912a805..2050927fc 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -73,5 +73,5 @@ Core never reads `$DSH_HOME/bot/flybot-workflow`. The plugin registers `flybot.t ## Connector ceiling (#1544) A connector a Bot switched off is not reopened by a Workflow, whichever surface started the run. A run records the Bots whose switches cap it, both in `cappedBy`: the Bot whose `run_workflow` call started it, and the Bot that saved the graph (`authoredBy` on the workflow record, set by `define_workflow` and by any save that names a Bot). A run Bot carries no `createdBy` (#1582): the roster reads that field as "a Bot made this Bot" and would start the run Bot on its maker's model, so the caller travels in `cappedBy` only — a ceiling, not a parent. Every node's run Bot carries `cappedBy` — the caller, else the graph's author — and `core/src/host/connector-access.js` intersects the node's `tools` with those switches, so a node that names a connector its caller or author turned off is refused with `turned off for ; the step's tools list cannot turn it back on`, and the tool is masked out of the run Bot's list too. A run nobody's tool call started (the board's `ingest`, the canvas, the `workflow/*` API) has no caller and is capped by the graph's author alone; a save with no Bot behind it records no author, which is the owner's own edit and leaves no ceiling; a graph saved before this has none either. `visibleTo` on `run_workflow`/`define_workflow` is unchanged: the ceiling is the fix, not limiting who may define a Workflow. -- **Strict order (#1729).** A graph may carry `strict: true` (normalized to a boolean on the record). A strict graph starts one node at a time in declared order: `startFrontier` keeps only the first ready node running and leaves its ready siblings `queued`, so no parallel fan-out happens even when two nodes share a rank. They start when the node before them finishes, in list order. The default is unchanged — a graph without the flag still fans out — and the release-shaped delivery template passes unchanged. Tests: `plugins/workflow/test/strict-order.test.js`. +- **Strict order (#1729).** A graph may carry `strict: true` (normalized to a boolean on the record). A strict graph starts one node at a time in declared order: `startFrontier` keeps only the first ready node running and leaves its ready siblings `queued`, so no parallel fan-out happens even when two nodes share a rank. They start when the node before them finishes, in list order. The default is unchanged — a graph without the flag still fans out — and the release-shaped delivery template passes unchanged. A second start against the persisted status after a crash between a claim and its result starts nothing that is already running and reaches no downstream node early, so no second downstream task is minted. The strict release graph (build → test → publish) and its refusal of a node whose required input was never produced are the regression (#1730). Tests: `plugins/workflow/test/strict-order.test.js`, `plugins/workflow/test/strict-release-dag.test.js`. - **Lease and delivery reads go through the host (#1729).** The plugin reads a card's lease with `fb.tasks.lease(taskId)` (`{ holder, takenAt, expiresAt, live, heldBy }`, or `undefined`) and the per-role/per-run delivery and resource counts with `fb.tasks.counters({ role, run })`. Both are host reads of the existing task rows (`rt.taskLease`, `rt.taskCounters`); the plugin keeps no copy, so an expired lease looks expired to both sides at once. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 35f003f16..3eaf7c009 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -42,6 +42,56 @@ module.exports = __toCommonJS(index_exports); +@@ -42,6 +42,56 @@ // src/client/i18n.js var NAMESPACE = "ds-bot"; var ZH = { @@ -60,7 +60,7 @@ diff --git a/client.js b/client.js // Shared "Main Bot": "主 Bot", Working: "工作中", -@@ -49,6 +49,67 @@ var ZH = { +@@ -49,6 +99,67 @@ "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -128,7 +128,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +63,21 @@ var ZH = { +@@ -63,6 +174,21 @@ Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -150,7 +150,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,72 @@ var ZH = { +@@ -78,21 +204,72 @@ " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -226,7 +226,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,59 @@ var ZH = { +@@ -129,6 +306,59 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -286,7 +286,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,64 @@ var ZH = { +@@ -141,17 +371,64 @@ "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -355,7 +355,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +162,6 @@ var ZH = { +@@ -162,8 +439,6 @@ "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -364,7 +364,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +187,24 @@ var ZH = { +@@ -187,9 +462,24 @@ Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -389,7 +389,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -205,6 +205,16 @@ var ZH = { +@@ -205,6 +495,16 @@ "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", @@ -406,7 +406,7 @@ diff --git a/client.js b/client.js "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", -@@ -247,6 +247,25 @@ var ZH = { +@@ -247,6 +547,25 @@ "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -432,7 +432,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +270,7 @@ var ZH = { +@@ -270,6 +589,7 @@ "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -440,7 +440,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +303,8 @@ var ZH = { +@@ -303,6 +623,8 @@ "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -449,7 +449,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +374,10 @@ var ZH = { +@@ -374,9 +696,10 @@ "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -462,7 +462,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +385,6 @@ var ZH = { +@@ -385,7 +708,6 @@ Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -470,7 +470,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +394,8 @@ var ZH = { +@@ -394,6 +716,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -479,7 +479,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +404,13 @@ var ZH = { +@@ -404,13 +728,13 @@ "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -499,7 +499,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,74 @@ var ZH = { +@@ -420,8 +744,74 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -575,7 +575,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,13 @@ var ZH = { +@@ -446,10 +836,13 @@ Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -589,7 +589,7 @@ diff --git a/client.js b/client.js "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", -@@ -459,6 +459,9 @@ var ZH = { +@@ -459,6 +852,9 @@ "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -599,7 +599,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +468,8 @@ var ZH = { +@@ -468,6 +864,8 @@ "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -608,7 +608,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +495,6 @@ var ZH = { +@@ -495,7 +893,6 @@ "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -616,7 +616,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,31 @@ var ZH = { +@@ -506,12 +903,31 @@ "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -649,7 +649,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -533,13 +533,14 @@ var ZH = { +@@ -533,13 +949,14 @@ Selected: "已选", Skip: "跳过", Submit: "提交", @@ -665,7 +665,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +555,6 @@ var ZH = { +@@ -555,7 +972,6 @@ "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -673,7 +673,7 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -582,7 +582,7 @@ var ZH = { +@@ -582,7 +998,7 @@ "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -682,7 +682,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -621,7 +621,6 @@ var ZH = { +@@ -621,7 +1037,6 @@ Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", @@ -690,7 +690,7 @@ diff --git a/client.js b/client.js "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", -@@ -747,11 +747,11 @@ var ZH = { +@@ -747,11 +1162,11 @@ "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -705,7 +705,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ var ZH = { +@@ -761,6 +1176,13 @@ "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -719,7 +719,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +776,7 @@ var ZH = { +@@ -776,7 +1198,7 @@ Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -728,7 +728,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +788,7 @@ var ZH = { +@@ -788,7 +1210,7 @@ "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -737,7 +737,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,12 +821,21 @@ var ZH = { +@@ -821,12 +1243,21 @@ "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -759,7 +759,7 @@ diff --git a/client.js b/client.js "Once, {time}": "一次,{time}", "Every day at {time}": "每天 {time}", "Every {days} at {time}": "每{days} {time}", -@@ -834,7 +834,21 @@ var ZH = { +@@ -834,7 +1265,21 @@ "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -781,7 +781,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -844,6 +844,7 @@ var ZH = { +@@ -844,6 +1289,7 @@ minutes: "分钟", hours: "小时", days: "天", @@ -789,7 +789,7 @@ diff --git a/client.js b/client.js "Morning briefing": "早间简报", "What to do": "要做什么", "Sum up what changed since yesterday and what needs me today.": "总结一下昨天以来的变化,以及今天需要我处理的事。", -@@ -856,9 +856,20 @@ var ZH = { +@@ -856,9 +1302,20 @@ Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -812,7 +812,7 @@ diff --git a/client.js b/client.js "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", "Automation tasks are off in DSH": "DSH 的「自动化任务」没有打开", -@@ -869,10 +869,19 @@ var ZH = { +@@ -869,10 +1326,19 @@ "Pick a date and time": "选一个日期和时间", "Pick a time in the future": "选一个将来的时间", "Pick a time of day": "选一个时间", @@ -832,7 +832,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +887,14 @@ var ZH = { +@@ -887,7 +1353,14 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -848,7 +848,7 @@ diff --git a/client.js b/client.js }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], -@@ -900,8 +900,11 @@ var HOST_ERRORS = [ +@@ -900,8 +1373,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, "只有你、主 Bot 或「$1」的管理员可以修改"], [/^A key must have at least (\d+) characters$/, "密钥至少 $1 个字符"], [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], @@ -860,7 +860,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -930,6 +930,20 @@ var HOST_ZH = { +@@ -930,6 +1406,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -881,7 +881,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,10 +951,12 @@ var HOST_ZH = { +@@ -951,10 +1441,12 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -896,7 +896,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1005,15 +1005,23 @@ function noteClientError(where, error) { +@@ -1005,15 +1497,23 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -923,7 +923,7 @@ diff --git a/client.js b/client.js if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); -@@ -1042,16 +1042,21 @@ var FAILURES = { +@@ -1042,16 +1542,21 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -951,7 +951,7 @@ diff --git a/client.js b/client.js const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} -@@ -1066,10 +1066,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- +@@ -1066,10 +1571,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -969,7 +969,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { +@@ -1087,7 +1598,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -978,7 +978,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1097,388 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1608,388 @@ return result.value; }; @@ -1367,7 +1367,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1160,9 +1160,9 @@ var MAIN_LOOK = Object.freeze({ shape: "whale", color: "deepseek" }); +@@ -1160,9 +2053,9 @@ var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -1379,7 +1379,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +1177,96 @@ function defaultShape(id) { +@@ -1177,19 +2070,96 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1479,7 +1479,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +2176,8 @@ return ""; } } @@ -1490,7 +1490,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,192 +1242,46 @@ function maskOf(shape) { +@@ -1242,192 +2212,46 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1702,7 +1702,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -1445,24 +1445,86 @@ function mentionSpans(text, bots, { references = [] } = {}) { +@@ -1445,24 +2269,86 @@ } return spans; } @@ -1794,7 +1794,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,9 +1483,18 @@ function relativeShort(ms) { +@@ -1483,9 +2369,18 @@ var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1816,7 +1816,7 @@ diff --git a/client.js b/client.js try { const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); -@@ -1493,38 +1493,121 @@ function reminderPrompts(text) { +@@ -1493,38 +2388,121 @@ return []; } } @@ -1945,7 +1945,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,17 +1597,46 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1597,17 +2575,46 @@ } return { count, peerIds }; } @@ -1995,7 +1995,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,9 +1624,982 @@ function usageOf(list, ids) { +@@ -1624,9 +2631,982 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2979,7 +2979,7 @@ diff --git a/client.js b/client.js var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +1659,12 @@ function liveSprings(apply2) { +@@ -1659,12 +3639,12 @@ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -2995,7 +2995,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +1699,10 @@ function liveSprings(apply2) { +@@ -1699,10 +3679,10 @@ } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -3009,7 +3009,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +1736,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1736,7 +3716,7 @@ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -3018,7 +3018,7 @@ diff --git a/client.js b/client.js const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +1750,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1750,7 +3730,7 @@ } place(fresh); }); @@ -3027,7 +3027,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +1758,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1758,16 +3738,16 @@ } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -3048,7 +3048,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +1788,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +@@ -1788,8 +3768,8 @@ }, [key]); } function useMorph(box, key) { @@ -3059,7 +3059,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +1817,8 @@ function useMorph(box, key) { +@@ -1817,8 +3797,8 @@ }); } function useLinger(value, ms) { @@ -3070,7 +3070,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- +@@ -1838,6 +3818,7 @@ .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -3078,7 +3078,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +3837,14 @@ @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -3093,7 +3093,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1933,15 +3922,23 @@ .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -3119,7 +3119,7 @@ diff --git a/client.js b/client.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -1950,27 +1950,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,27 +3947,32 @@ .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3156,7 +3156,7 @@ diff --git a/client.js b/client.js .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} -@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1992,6 +3994,7 @@ .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -3164,7 +3164,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2085,6 +4088,7 @@ @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -3172,7 +3172,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2098,7 +4102,9 @@ .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3183,7 +3183,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2127,6 +4133,10 @@ .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -3194,7 +3194,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2150,11 +2150,11 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2150,11 +4160,11 @@ .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} @@ -3208,7 +3208,7 @@ diff --git a/client.js b/client.js .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} -@@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2163,6 +4173,8 @@ .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -3217,7 +3217,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2197,7 +2197,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2197,7 +4209,7 @@ .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} @@ -3226,7 +3226,7 @@ diff --git a/client.js b/client.js .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} -@@ -2237,9 +2237,23 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2237,9 +4249,23 @@ .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ @@ -3251,7 +3251,7 @@ diff --git a/client.js b/client.js .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} -@@ -2255,6 +2255,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +@@ -2255,6 +4281,7 @@ .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -3259,7 +3259,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2288,13 +2288,60 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2288,13 +4315,60 @@ .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -3320,7 +3320,7 @@ diff --git a/client.js b/client.js /* Settings → Connectors. */ .bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -@@ -2308,8 +2308,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2308,8 +4382,17 @@ .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -3338,7 +3338,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2361,7 +2361,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2361,7 +4444,7 @@ .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} @@ -3347,7 +3347,7 @@ diff --git a/client.js b/client.js .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} -@@ -2380,7 +2380,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2380,7 +4463,7 @@ .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3356,7 +3356,7 @@ diff --git a/client.js b/client.js .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} -@@ -2390,7 +2390,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2390,7 +4473,7 @@ .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} @@ -3365,7 +3365,7 @@ diff --git a/client.js b/client.js .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} -@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2427,7 +4510,8 @@ .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -3375,7 +3375,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2494,7 +2494,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2494,7 +4578,7 @@ .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} @@ -3384,7 +3384,7 @@ diff --git a/client.js b/client.js .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} -@@ -2597,9 +2597,20 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2597,9 +4681,20 @@ `; var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} @@ -3407,7 +3407,7 @@ diff --git a/client.js b/client.js .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} .bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` +@@ -2612,7 +4707,6 @@ .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -3415,7 +3415,7 @@ diff --git a/client.js b/client.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -2622,21 +2622,48 @@ var CONVERSATION_CSS = ` +@@ -2622,21 +4716,48 @@ [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -3472,7 +3472,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2646,8 +2646,19 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2646,8 +4767,19 @@ [data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} [data-chat-running]>span:not([role=status]){display:none!important} [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} @@ -3492,7 +3492,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2679,7 +4811,8 @@ @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -3502,7 +3502,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2691,6 +4824,8 @@ [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} @@ -3511,7 +3511,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,7 +2722,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2722,7 +4857,9 @@ .bt-mic:active,.bt-voice:active{scale:.9} [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} body[data-bt-room] .bt-voice{display:none!important} @@ -3521,7 +3521,7 @@ diff --git a/client.js b/client.js .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} .bt-q{order:1} -@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2776,6 +4913,7 @@ .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} @@ -3529,7 +3529,7 @@ 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} -@@ -2784,10 +2784,10 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2784,10 +4922,10 @@ .bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} .bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} `; @@ -3542,7 +3542,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2807,9 +2807,9 @@ function sheetClassNames(sheet) { +@@ -2807,9 +4945,9 @@ } return names; } @@ -3554,7 +3554,7 @@ diff --git a/client.js b/client.js if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -@@ -2818,7 +2818,7 @@ function compileFragmentSelectors(text, names) { +@@ -2818,7 +4956,7 @@ return compiled.get(fragment); }); } @@ -3563,7 +3563,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -2832,7 +2832,7 @@ function installCompiledStyles(text, tag) { +@@ -2832,7 +4970,7 @@ } for (const name of list) names.add(name); } @@ -3572,7 +3572,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -2855,226 +2855,204 @@ function installCompiledStyles(text, tag) { +@@ -2855,226 +4993,204 @@ }; } @@ -3954,7 +3954,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3086,6 +3086,7 @@ var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]) +@@ -3086,6 +5202,7 @@ var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); @@ -3962,7 +3962,7 @@ diff --git a/client.js b/client.js var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); -@@ -3093,14 +3093,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 +@@ -3093,14 +5210,14 @@ var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -3981,7 +3981,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +3112,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h +@@ -3112,21 +5229,48 @@ var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -4033,7 +4033,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3146,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +5290,10 @@ }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -4048,7 +4048,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3167,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +5311,7 @@ }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -4057,7 +4057,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3186,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +5330,7 @@ }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -4066,7 +4066,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +3196,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +5340,36 @@ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -4116,7 +4116,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3235,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +5379,24 @@ submit(); } }, @@ -4151,7 +4151,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +3262,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +5406,29 @@ const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -4192,7 +4192,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +3292,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +5436,8 @@ function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -4203,7 +4203,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3305,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +5449,10 @@ } pending.answer({ answers: next }); }; @@ -4216,7 +4216,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3318,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +5462,10 @@ setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -4229,7 +4229,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3329,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +5473,16 @@ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -4249,7 +4249,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { +@@ -3346,37 +5490,37 @@ } // src/client/secret-card.js @@ -4299,7 +4299,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,42 +3401,42 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3401,42 +5545,42 @@ }); } function ScopeSwitch({ value, onChange, bot }) { @@ -4359,7 +4359,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3452,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +5596,73 @@ if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -4459,7 +4459,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +5675,46 @@ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -4522,7 +4522,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3583,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +5729,66 @@ setValue(next); setReplaced(false); }; @@ -4615,7 +4615,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +3653,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +5799,43 @@ } }); }; @@ -4677,7 +4677,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +3756,144 @@ var SECRET_CSS = ` +@@ -3756,6 +5902,144 @@ @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -4822,7 +4822,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +3767,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +6051,7 @@ var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4831,7 +4831,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,29 +3780,29 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,29 +6064,29 @@ actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4869,7 +4869,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +3813,22 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3813,9 +6097,22 @@ }) ); } @@ -4893,7 +4893,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3835,12 +3835,12 @@ function useDismiss(open, close, refs) { +@@ -3835,12 +6132,12 @@ }; }, [open]); } @@ -4912,7 +4912,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3854,36 +3854,36 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3854,36 +6151,36 @@ setPanel(kind); }; const copy2 = () => { @@ -4960,7 +4960,7 @@ diff --git a/client.js b/client.js "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3891,12 +3891,40 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3891,12 +6188,40 @@ ), document.body) : null ); } @@ -5005,7 +5005,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +3904,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,19 +6229,33 @@ const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -5041,7 +5041,7 @@ diff --git a/client.js b/client.js const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +3924,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3924,13 +6263,14 @@ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; @@ -5059,7 +5059,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,230 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +6280,230 @@ whole: true }); }), @@ -5313,7 +5313,7 @@ diff --git a/client.js b/client.js ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4005,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +6514,14 @@ const target = other && other.id !== self?.id ? other : self; if (!target) return null; const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); @@ -5331,7 +5331,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4021,11 @@ var scrollParent = (node) => { +@@ -4021,11 +6530,11 @@ return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -5348,7 +5348,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4041,19 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4041,19 +6550,19 @@ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -5371,7 +5371,7 @@ diff --git a/client.js b/client.js const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4069,35 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4069,35 +6578,35 @@ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -5418,7 +5418,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4105,7 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4105,7 +6614,7 @@ return null; } }), @@ -5427,7 +5427,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4116,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4116,7 +6625,7 @@ const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -5436,7 +5436,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +6644,31 @@ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -5475,7 +5475,7 @@ diff --git a/client.js b/client.js const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4165,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4165,7 +6682,7 @@ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -5484,7 +5484,7 @@ diff --git a/client.js b/client.js prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4174,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4174,24 +6691,24 @@ } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -5514,7 +5514,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4199,66 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,11 +6716,66 @@ const own = state[sessionId]?.[turn]; return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; }); @@ -5582,7 +5582,7 @@ diff --git a/client.js b/client.js var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); -@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { +@@ -4233,10 +6805,19 @@ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; @@ -5603,7 +5603,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4250,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions +@@ -4250,15 +6831,15 @@ return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -5624,7 +5624,7 @@ diff --git a/client.js b/client.js } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +4320,22 @@ function toolActivity(name, arg, findBot, self) { +@@ -4320,19 +6901,22 @@ } function workPhrase(tool) { if (!tool) return t("Thinking about it"); @@ -5652,7 +5652,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +4341,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4341,10 +6925,10 @@ const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -5665,7 +5665,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +4362,89 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4362,71 +6946,89 @@ replies = []; } } @@ -5786,7 +5786,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +4435,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +@@ -4435,52 +7037,52 @@ function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -5855,7 +5855,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +4492,90 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot +@@ -4492,64 +7094,90 @@ post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -5964,7 +5964,7 @@ diff --git a/client.js b/client.js var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +4559,69 @@ function elapsedLabel(ms) { +@@ -4559,9 +7187,69 @@ const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -6037,7 +6037,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +4569,7 @@ function useRunningHost(active) { +@@ -4569,7 +7257,7 @@ let frame = 0; const find = () => { frame = 0; @@ -6046,7 +6046,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +4581,13 @@ function useRunningHost(active) { +@@ -4581,13 +7269,13 @@ cancelAnimationFrame(frame); observer.disconnect(); }; @@ -6063,7 +6063,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +4638,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4638,21 +7326,31 @@ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; @@ -6102,7 +6102,7 @@ diff --git a/client.js b/client.js if (wantKey === null || shown?.key === wantKey) return void 0; const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,26 +4663,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4663,26 +7361,31 @@ const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -6146,7 +6146,7 @@ diff --git a/client.js b/client.js setShown(null); const before = shift.current; shift.current = null; -@@ -4691,31 +4691,37 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4691,31 +7394,37 @@ const delta = before - scroller.scrollTop; if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); }, [phase]); @@ -6196,7 +6196,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,1687 @@ function showsDshRow(dsh, app) { +@@ -4731,49 +7440,1687 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -7897,7 +7897,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4785,131 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,55 +9132,131 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -8044,7 +8044,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +9264,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -8073,7 +8073,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +9298,10 @@ } })(); }; @@ -8086,7 +8086,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +9314,7 @@ }) ); } @@ -8095,7 +8095,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +9328,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8106,7 +8106,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +9364,10 @@ const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -8119,7 +8119,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +9382,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8161,7 +8161,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +9419,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -8172,7 +8172,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +9437,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -8218,7 +8218,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { +@@ -5025,17 +9483,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -8249,7 +8249,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5045,42 @@ function TeamSidebar(props) { +@@ -5045,39 +9512,42 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -8312,7 +8312,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5116,14 +9586,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -8332,7 +8332,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +9603,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -8365,7 +8365,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +9625,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -8390,7 +8390,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5176,61 +9646,25 @@ ), document.body) : null ); } @@ -8462,7 +8462,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { +@@ -5259,6 +9693,18 @@ else openHarnessSettings(); }); } @@ -8481,7 +8481,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +9734,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -8522,7 +8522,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5341,30 +9787,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -8567,7 +8567,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +9820,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -8576,7 +8576,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +9830,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -8587,7 +8587,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +5397,40 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5397,40 +9845,40 @@ return void 0; } const last = lastBot.current; @@ -8638,7 +8638,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5456,7 +5456,7 @@ var SIDEBAR_CSS = ` +@@ -5456,7 +9904,7 @@ .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} @@ -8647,7 +8647,7 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5491,18 +5491,140 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +9939,140 @@ .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -8793,7 +8793,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,19 +10082,19 @@ return shown; } function MemoryNotice({ shown }) { @@ -8819,7 +8819,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); const firstTurns = useSessions((list) => { -@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5537,7 +10107,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -8828,7 +8828,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,41 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +10118,41 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -8901,7 +8901,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +5642,7 @@ function useTranscriptView() { +@@ -5642,7 +10201,7 @@ }, []); return view; } @@ -8910,7 +8910,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +10217,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -8938,7 +8938,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +5677,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5677,16 +10240,16 @@ }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -8959,7 +8959,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +10257,7 @@ event.stopPropagation(); onDismiss(); } @@ -8968,7 +8968,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5703,9 +10266,9 @@ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -8981,7 +8981,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5714,135 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5714,19 +10277,135 @@ ); } @@ -9125,7 +9125,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5745,10 +5745,10 @@ function useDictation(onText) { +@@ -5745,10 +10424,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -9139,7 +9139,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +5774,38 @@ function useDictation(onText) { +@@ -5774,19 +10453,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -9183,7 +9183,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +5797,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +10495,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -9197,7 +9197,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +5808,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5808,16 +10506,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -9218,7 +9218,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5825,17 @@ function menuIcons(roster) { +@@ -5825,17 +10523,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -9241,7 +9241,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +5876,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +10574,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -9255,7 +9255,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +10587,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -9266,7 +9266,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +10598,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -9279,7 +9279,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,57 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +10620,57 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -9341,7 +9341,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5959,31 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +10699,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -9377,7 +9377,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5995,6 +10752,83 @@ return [bots, chats]; } @@ -9461,7 +9461,7 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6008,8 +6008,8 @@ async function speechCatalog(speech) { +@@ -6008,8 +10842,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -9472,7 +9472,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -6030,9 +6030,9 @@ function waveBytes(samples, rate = 16e3) { +@@ -6030,9 +10864,9 @@ return bytes; } function base64Of(bytes) { @@ -9485,7 +9485,7 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -6121,233 +6121,9 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +@@ -6121,233 +10955,9 @@ }; } @@ -9721,7 +9721,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS = 120; function groupModels(models) { const groups = /* @__PURE__ */ new Map(); -@@ -6367,17 +6367,10 @@ function groupModels(models) { +@@ -6367,17 +10977,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -9741,7 +9741,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6402,12 @@ function effortRows(reasoning) { +@@ -6402,12 +11005,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -9759,7 +9759,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +11019,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -9783,7 +9783,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +11039,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -9812,7 +9812,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +6484,7 @@ function useAnchoredMenu() { +@@ -6484,6 +11087,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -9820,7 +9820,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +6511,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6511,10 +11115,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -9834,7 +9834,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +6527,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6527,37 +11131,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -9887,7 +9887,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6569,13 +6569,13 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6569,13 +11174,13 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -9906,7 +9906,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,50 +11189,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -10038,7 +10038,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +11309,16 @@ }); } var MODEL_CSS = ` @@ -10055,7 +10055,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6675,96 @@ var MODEL_CSS = ` +@@ -6675,18 +11355,96 @@ @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -10160,7 +10160,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +11477,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -10212,7 +10212,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +11517,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -10262,7 +10262,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +6798,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +11556,19 @@ title: value, onClick: toggle }, @@ -10288,7 +10288,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +11588,8 @@ `; // src/client/schedules.js @@ -10299,7 +10299,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +6839,8 @@ function publish(view) { +@@ -6839,8 +11597,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -10310,7 +10310,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +6848,7 @@ function useSchedules(actions) { +@@ -6848,7 +11606,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -10319,7 +10319,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +6873,8 @@ function weekdayNames(days) { +@@ -6873,7 +11631,8 @@ function everyText(seconds) { if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); @@ -10329,7 +10329,7 @@ diff --git a/client.js b/client.js } function timingText(task) { const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -@@ -6887,8 +6887,8 @@ function timingText(task) { +@@ -6887,8 +11646,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -10340,7 +10340,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -6896,11 +6896,25 @@ function timingText(task) { +@@ -6896,11 +11655,25 @@ return ""; } } @@ -10371,7 +10371,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,49 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +11686,49 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -10430,7 +10430,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +6934,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +11736,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -10439,7 +10439,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6944,27 +6944,49 @@ function inAnHour() { +@@ -6944,27 +11746,49 @@ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; @@ -10506,7 +10506,7 @@ diff --git a/client.js b/client.js setBusy(true); setError(""); let when3; -@@ -6975,151 +6975,298 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6975,151 +11799,298 @@ setBusy(false); return; } @@ -10867,7 +10867,7 @@ diff --git a/client.js b/client.js .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -@@ -7127,6 +7127,12 @@ var SCHEDULE_CSS = ` +@@ -7127,6 +12098,12 @@ .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -10880,7 +10880,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,12 +7158,12 @@ var SCHEDULE_CSS = ` +@@ -7158,12 +12135,12 @@ `; // src/client/capability-settings.js @@ -10896,7 +10896,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +12149,11 @@ }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -10911,7 +10911,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +12165,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -10920,7 +10920,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7199,7 +12176,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -10929,7 +10929,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +12196,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -11065,7 +11065,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +12307,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -11093,7 +11093,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +12336,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -11106,7 +11106,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7372,9 +12349,55 @@ ); } @@ -11164,7 +11164,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { +@@ -7384,13 +12407,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -11181,7 +11181,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +12427,16 @@ function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11204,7 +11204,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7428,45 @@ function ago(at, now) { +@@ -7428,45 +12451,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -11269,7 +11269,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +12501,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -11282,7 +11282,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +12518,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -11304,7 +11304,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +7512,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +12535,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -11313,7 +11313,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,82 +7521,82 @@ function Entries({ bot, view, remove }) { +@@ -7521,82 +12544,82 @@ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -11431,7 +11431,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { +@@ -7606,9 +12629,9 @@ }, [botId]); return { view, error, load, show }; } @@ -11444,7 +11444,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +7616,18 @@ function useNow(every) { +@@ -7616,18 +12639,18 @@ } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -11473,7 +11473,7 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7671,7 +7671,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7671,7 +12694,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -11482,7 +11482,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,65 +7689,65 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,65 +12712,65 @@ const when3 = view?.summary ? ago(view.summary.at, now) : null; const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; const busy = answer?.pending === true; @@ -11575,7 +11575,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7755,8 +12778,40 @@ ); } @@ -11617,7 +11617,7 @@ diff --git a/client.js b/client.js var capitalize = (word) => word[0].toUpperCase() + word.slice(1); async function avatarImage(file) { if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -@@ -7780,8 +7780,8 @@ async function avatarImage(file) { +@@ -7780,8 +12835,8 @@ throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -11628,7 +11628,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +7792,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7792,7 +12847,7 @@ if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -11637,7 +11637,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7813,29 +12868,29 @@ }); } function Row({ label, hint, stack = false, children }) { @@ -11676,7 +11676,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +12901,22 @@ return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -11704,7 +11704,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7869,8 +12924,8 @@ title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -11715,7 +11715,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7878,19 +12933,19 @@ title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -11741,7 +11741,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7908,7 +12963,7 @@ window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -11750,7 +11750,7 @@ diff --git a/client.js b/client.js if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7922,32 +12977,32 @@ }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -11791,7 +11791,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7963,15 +13018,15 @@ upload(event.dataTransfer.files?.[0]); } }, @@ -11813,7 +11813,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +7982,27 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,26 +13037,27 @@ function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11849,7 +11849,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,112 +8011,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +13067,116 @@ if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -12015,7 +12015,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8130,9 +8130,17 @@ var BOT_SETTINGS_CSS = ` +@@ -8130,9 +13190,17 @@ .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} @@ -12034,7 +12034,7 @@ diff --git a/client.js b/client.js .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -@@ -8153,8 +8153,659 @@ label.bt-bs-row{cursor:text} +@@ -8153,8 +13221,659 @@ .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} @@ -12694,7 +12694,7 @@ diff --git a/client.js b/client.js // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); -@@ -8165,36 +8165,48 @@ function freshBotName(roster, base = "New Bot") { +@@ -8165,36 +13884,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -12758,7 +12758,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +8206,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8206,8 +13937,8 @@ setError(""); setBusy(true); try { @@ -12769,7 +12769,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +8219,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +13950,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12803,7 +12803,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8249,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +13980,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -12839,7 +12839,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +8284,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,75 +14015,113 @@ } } }), @@ -12983,7 +12983,7 @@ diff --git a/client.js b/client.js const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); -@@ -8386,71 +8386,104 @@ function NewChat({ mode, roster, actions }) { +@@ -8386,71 +14155,104 @@ }; const created = (bot) => { close(); @@ -13108,7 +13108,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +8467,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +14269,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -13165,7 +13165,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +8517,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +14327,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -13191,7 +13191,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +14352,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -13237,7 +13237,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +8569,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +14394,7 @@ } } }), @@ -13246,7 +13246,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +14402,41 @@ } // src/client/exchange.js @@ -13292,7 +13292,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,49 +8612,42 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,49 +14460,42 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -13356,7 +13356,7 @@ diff --git a/client.js b/client.js var VOICE_LABELS = { listening: "Listening", thinking: "Thinking", -@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { +@@ -8665,14 +14506,14 @@ }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -13374,7 +13374,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +14557,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -13404,7 +13404,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8735,24 +8735,24 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8735,24 +14576,24 @@ actions.closeVoice(); }; useEscape(close); @@ -13437,7 +13437,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +8767,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +14608,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -13457,7 +13457,7 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8789,9 +8789,9 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8789,9 +14630,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -13469,7 +13469,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -8838,12 +8838,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8838,12 +14679,12 @@ setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -13486,7 +13486,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -8858,7 +8858,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8858,7 +14699,7 @@ window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -13495,7 +13495,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,291 +8875,605 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,291 +14716,605 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -14339,7 +14339,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +9277,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { +@@ -9277,13 +15432,13 @@ } return result; } @@ -14357,7 +14357,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9294,11 @@ function percent(part, whole) { +@@ -9294,11 +15449,11 @@ } // src/client/usage-page.js @@ -14371,7 +14371,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +9341,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +15496,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -14384,7 +14384,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9363,9 @@ function useTween(value) { +@@ -9363,9 +15518,9 @@ }, [value]); return shown; } @@ -14396,7 +14396,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +9382,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +15537,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -14405,7 +14405,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +15547,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -14414,7 +14414,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +9411,33 @@ function kindLabel(kind, botFocus) { +@@ -9411,33 +15566,33 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -14459,7 +14459,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +9455,83 @@ function niceTop(max) { +@@ -9455,83 +15610,83 @@ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -14571,7 +14571,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +9540,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +15695,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -14669,7 +14669,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +9628,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +15783,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -14690,7 +14690,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +9654,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +15809,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -14712,7 +14712,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +9671,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +15826,30 @@ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -14751,7 +14751,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +9710,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +15865,13 @@ for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -14767,7 +14767,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +9724,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +15879,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -14776,7 +14776,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +15888,61 @@ } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -14864,7 +14864,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +9797,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +15952,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -14873,7 +14873,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +9814,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +15969,7 @@ }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -14882,7 +14882,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +9823,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +15978,7 @@ const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -14891,7 +14891,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +9871,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +16026,23 @@ }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -14923,7 +14923,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +16067,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -14932,7 +14932,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +9924,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +16079,7 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -14941,7 +14941,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +9936,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +16091,30 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -14982,7 +14982,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +16123,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -15036,7 +15036,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +10150,216 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10150,33 +16305,216 @@ @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -15089,16 +15089,16 @@ diff --git a/client.js b/client.js { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react25.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) + (0, import_react37.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) - ); --var MARKS = { github: GitHubMark }; --var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; ++); +var FeishuMark = () => (0, import_react37.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + (0, import_react37.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), + (0, import_react37.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), + (0, import_react37.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) -+); + ); +-var MARKS = { github: GitHubMark }; +-var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; +var ABOUT = { + github: () => t("Repositories, issues, and pull requests"), @@ -15264,7 +15264,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +10184,195 @@ function ConnectorRow({ connector, actions }) { +@@ -10184,83 +16522,195 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -15490,7 +15490,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +10270,18 @@ function entrySummary(entry) { +@@ -10270,18 +16720,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -15516,7 +15516,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10289,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +16739,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -15525,7 +15525,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,8 +10307,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10307,8 +16757,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -15536,7 +15536,7 @@ diff --git a/client.js b/client.js setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); -@@ -10317,59 +10317,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +16767,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -15624,7 +15624,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +16827,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -15635,7 +15635,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +16839,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -15657,7 +15657,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +10416,56 @@ var FEEDBACK_CSS = ` +@@ -10416,47 +16867,56 @@ .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -15728,7 +15728,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +10466,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,87 +16926,72 @@ live = false; }; }, []); @@ -15839,7 +15839,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +17014,59 @@ `; // src/client/settings.js @@ -15917,7 +15917,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,86 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +17076,86 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -16027,7 +16027,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +10706,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,69 +17167,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -16127,7 +16127,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10779,113 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +17240,113 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -16282,7 +16282,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +10884,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10884,13 +17354,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -16307,7 +16307,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +17380,22 @@ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -16336,7 +16336,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +17404,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16355,7 +16355,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +17433,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -16375,7 +16375,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,16 +10968,31 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,16 +17449,31 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -16408,7 +16408,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11038,244 +11038,8 @@ function migrateStorage(storage = globalThis.localStorage) { +@@ -11038,244 +17534,8 @@ } } @@ -16654,7 +16654,7 @@ diff --git a/client.js b/client.js var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +17549,10 @@ var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -16667,7 +16667,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11302,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +17562,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -16686,7 +16686,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11323,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +17583,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -16744,7 +16744,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,195 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +17627,195 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -16985,7 +16985,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11457,7 +11457,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11457,7 +17825,7 @@ const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -16994,7 +16994,7 @@ diff --git a/client.js b/client.js const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -11467,41 +11467,67 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11467,41 +17835,67 @@ return false; }); const share = () => { @@ -17078,7 +17078,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +11515,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +17909,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -17095,7 +17095,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,98 +11535,97 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,98 +17929,97 @@ setEditing(false); } } @@ -17239,7 +17239,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +11674,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +18067,11 @@ var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -17256,7 +17256,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +11707,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +18100,7 @@ trail.cancel(); }; }); @@ -17265,7 +17265,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +11721,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +18114,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -17282,7 +17282,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +11737,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +18130,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -17299,7 +17299,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +11764,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +18157,9 @@ } } }, @@ -17312,7 +17312,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +11778,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +18171,20 @@ }, "×") ); }), @@ -17340,7 +17340,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,43 +11817,43 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,43 +18210,43 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -17397,7 +17397,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +11877,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +18270,25 @@ } }) ), @@ -17433,7 +17433,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +18300,41 @@ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -17491,7 +17491,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +11955,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +18348,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -17527,7 +17527,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12013,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +18406,7 @@ return actions[name](...args); }; } @@ -17536,7 +17536,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12096,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +18489,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -17545,7 +17545,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +12113,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +18506,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -17566,7 +17566,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,9 +12217,79 @@ var BROWSER_CSS = ` +@@ -12217,9 +18610,79 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -17648,7 +17648,7 @@ diff --git a/client.js b/client.js var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) { -@@ -12227,15 +12227,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +18690,15 @@ const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -17668,7 +17668,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +12245,26 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12245,19 +18708,26 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -17705,7 +17705,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +12294,19 @@ function readPage(limits) { +@@ -12294,19 +18764,19 @@ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; @@ -17732,7 +17732,7 @@ diff --git a/client.js b/client.js const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; -@@ -12340,8 +12340,8 @@ function readPage(limits) { +@@ -12340,8 +18810,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -17743,7 +17743,7 @@ diff --git a/client.js b/client.js ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); -@@ -12372,7 +12372,7 @@ ${fence}`; +@@ -12372,7 +18842,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -17752,7 +17752,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} +@@ -12398,11 +18868,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -17766,7 +17766,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +12410,18 @@ ${children(cell, ctx)} +@@ -12410,18 +18880,18 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -17790,7 +17790,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12480,9 +12480,9 @@ ${body2 || "(no text)"} +@@ -12480,9 +18950,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -17803,7 +17803,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -12552,7 +12552,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} +@@ -12552,7 +19022,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -17812,7 +17812,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -12570,12 +12570,12 @@ ${marked} +@@ -12570,12 +19040,12 @@ return marked; } const body = document.body; @@ -17829,7 +17829,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -12585,7 +12585,7 @@ ${marked} +@@ -12585,7 +19055,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -17838,7 +17838,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -12613,15 +12613,15 @@ var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.in +@@ -12613,15 +19083,15 @@ var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { @@ -17862,7 +17862,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +13052,285 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +19522,295 @@ }; } @@ -17965,6 +17965,8 @@ diff --git a/client.js b/client.js +.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} +.bt-project-row[data-column="done"]{opacity:.7} ++.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} ++.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} @@ -18001,6 +18003,7 @@ diff --git a/client.js b/client.js + nodeId: text(row.nodeId) || null, + title: text(task?.title) || text(task?.note) || null, + priority: text(task?.priority) || null, ++ audit: text(row.audit) || null, + board: boardOfRow(row, task) + }; + }) @@ -18039,7 +18042,14 @@ diff --git a/client.js b/client.js + } + }, + (0, import_react46.createElement)("span", { className: "bt-project-column" }, t(row.column)), -+ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), ++ (0, import_react46.createElement)( ++ "span", ++ { className: "bt-project-title-wrap" }, ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")), ++ // The audit text the row already carries (#1730): shown inside the card detail, never a ++ // second read. A row without one renders exactly as before. ++ row.audit === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-audit" }, row.audit) ++ ), + row.priority === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-priority" }, row.priority), + row.runId === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + ); @@ -18149,7 +18159,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +13075,8 @@ function surfaceGroups(install) { +@@ -13075,8 +19829,8 @@ }; return { reconcile(wanted) { @@ -18160,7 +18170,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +13086,7 @@ function surfaceGroups(install) { +@@ -13086,7 +19840,7 @@ } }, dispose() { @@ -18169,7 +18179,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +19859,7 @@ } // src/client/user-message.js @@ -18178,7 +18188,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,87 +13119,165 @@ function contentParts(content) { +@@ -13119,87 +19873,165 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -18378,7 +18388,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +13216,115 @@ var USER_MESSAGE_CSS = ` +@@ -13216,42 +20048,115 @@ `; // src/client/approval-notices.js @@ -18507,7 +18517,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13274,13 @@ function apply(ctx) { +@@ -13274,7 +20179,13 @@ } catch { seenAt = {}; } @@ -18522,7 +18532,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13308,6 +13308,8 @@ function apply(ctx) { +@@ -13308,6 +20219,8 @@ return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -18531,7 +18541,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +13344,25 @@ function apply(ctx) { +@@ -13344,13 +20257,25 @@ } return { ...state, unread }; }); @@ -18558,7 +18568,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13358,6 +13358,11 @@ function apply(ctx) { +@@ -13358,6 +20283,11 @@ activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -18570,7 +18580,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +13368,15 @@ function apply(ctx) { +@@ -13368,10 +20298,15 @@ const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -18586,7 +18596,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13488,20 +13488,12 @@ function apply(ctx) { +@@ -13488,20 +20423,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -18609,7 +18619,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13520,7 @@ function apply(ctx) { +@@ -13520,7 +20447,7 @@ const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -18618,7 +18628,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +13536,7 @@ function apply(ctx) { +@@ -13536,7 +20463,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -18627,7 +18637,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13588,7 @@ function apply(ctx) { +@@ -13588,6 +20515,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -18635,7 +18645,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13653,12 @@ function apply(ctx) { +@@ -13653,12 +20581,12 @@ }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -18651,7 +18661,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13708,15 @@ function apply(ctx) { +@@ -13708,10 +20636,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -18669,7 +18679,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +13719,19 @@ function apply(ctx) { +@@ -13719,6 +20652,19 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -18689,7 +18699,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +13728,7 @@ function apply(ctx) { +@@ -13728,7 +20674,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -18698,7 +18708,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +13757,12 @@ function apply(ctx) { +@@ -13757,6 +20703,12 @@ const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -18711,7 +18721,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13780,7 +13780,7 @@ function apply(ctx) { +@@ -13780,7 +20732,7 @@ } return { ...state, reactions }; }), @@ -18720,7 +18730,7 @@ diff --git a/client.js b/client.js clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), -@@ -13808,10 +13808,16 @@ function apply(ctx) { +@@ -13808,10 +20760,16 @@ setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), @@ -18738,7 +18748,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,31 @@ function apply(ctx) { +@@ -13819,15 +20777,31 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -18771,7 +18781,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13836,7 +13836,7 @@ function apply(ctx) { +@@ -13836,7 +20810,7 @@ usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), @@ -18780,7 +18790,7 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -13846,6 +13846,8 @@ function apply(ctx) { +@@ -13846,6 +20820,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -18789,7 +18799,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +13890,7 @@ function apply(ctx) { +@@ -13890,6 +20866,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -18797,7 +18807,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13922,6 +13922,9 @@ function apply(ctx) { +@@ -13922,6 +20899,9 @@ wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -18807,7 +18817,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { -@@ -13964,7 +13964,13 @@ function apply(ctx) { +@@ -13964,7 +20944,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -18822,7 +18832,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13989,17 @@ function apply(ctx) { +@@ -13989,7 +20975,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -18840,7 +18850,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +14001,12 @@ function apply(ctx) { +@@ -14001,7 +20997,12 @@ const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -18853,7 +18863,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +14010,14 @@ function apply(ctx) { +@@ -14010,6 +21011,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -18868,7 +18878,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,49 @@ function apply(ctx) { +@@ -14019,33 +21028,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -18922,7 +18932,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +14057,24 @@ function apply(ctx) { +@@ -14057,15 +21082,24 @@ // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -18950,7 +18960,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,9 +14074,18 @@ function apply(ctx) { +@@ -14074,9 +21108,18 @@ shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), diff --git a/patches/ds-bot/0017-task-pool-role-queues.patch b/patches/ds-bot/0017-task-pool-role-queues.patch index be47a216f..4d8ea49ee 100644 --- a/patches/ds-bot/0017-task-pool-role-queues.patch +++ b/patches/ds-bot/0017-task-pool-role-queues.patch @@ -24,7 +24,7 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js const readRole = (value) => { if (value === undefined || value === null) return undefined const role = oneLine(value, ROLE_MAX) -@@ -480,6 +494,63 @@ +@@ -480,6 +494,113 @@ task.taskClass = 'work' return task } @@ -55,7 +55,14 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js + const runs = {} + const wanted = oneLine(query?.role ?? '', ROLE_MAX) + const wantedRun = query?.run === undefined || query?.run === null ? '' : String(query.run) -+ for (const task of Object.values(store())) { ++ const all = Object.values(store()) ++ // A rework chain is one accepted delivery (#1730): an accepted parent and the `rework` child ++ // that resolved it are two records of the same delivery. The child still counts in ++ // `accepted`; `deliveries` counts the chain once, at its accepted parent. A child whose ++ // parent never landed is a delivery of its own — there is no chain to fold it into. ++ const acceptedIds = new Set(all.filter(task => task?.status === 'accepted').map(task => task.id)) ++ const countsAsDelivery = task => task.taskClass !== 'rework' || !acceptedIds.has(task.reworkOf) ++ for (const task of all) { + const role = roleOf(task) + const run = String(task?.origin?.runId ?? task?.runId ?? '') + if (wanted !== '' && role !== wanted) continue @@ -64,18 +71,61 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js + const row = bucket(roles, role || 'unassigned') + if (row[task.status] !== undefined) row[task.status] += 1 + if (task.status === 'accepted') { -+ row.deliveries += 1 ++ if (countsAsDelivery(task)) row.deliveries += 1 + const lease = leaseOf(task) + if (lease !== undefined && Number.isFinite(lease.takenAt)) row.workerSeconds += Math.max(0, Math.round((task.updatedAt - lease.takenAt) / 1000)) + } + if (run !== '') { + const runRow = bucket(runs, run) + if (runRow[task.status] !== undefined) runRow[task.status] += 1 -+ if (task.status === 'accepted') runRow.deliveries += 1 ++ if (task.status === 'accepted' && countsAsDelivery(task)) runRow.deliveries += 1 + } + } + return { roles, runs } + } ++ // One audit block from the typed rows (#1730). A projection, not a store: every line is read ++ // from fields the write path already validated, so rendering the same rows twice is the same ++ // text. A record stored before the slice carries none of the fields and answers the empty ++ // string — an audit of nothing is not an error. ++ const isoStamp = at => (Number.isFinite(at) ? new Date(at).toISOString() : '') ++ const auditLine = (task) => { ++ const lines = [] ++ const handoff = task.handoff !== null && typeof task.handoff === 'object' ? task.handoff : undefined ++ if (handoff !== undefined && handoff.from && handoff.to) { ++ const reason = typeof handoff.reason === 'string' && handoff.reason !== '' ? `: ${handoff.reason}` : '' ++ lines.push(`handoff ${handoff.from} -> ${handoff.to}${reason} @ ${isoStamp(handoff.at)}`) ++ } ++ if (Array.isArray(task.evidence)) { ++ for (const entry of task.evidence) { ++ if (entry === null || typeof entry !== 'object') continue ++ const parts = [] ++ if (entry.sha) parts.push(String(entry.sha)) ++ if (typeof entry.pr === 'number') parts.push(`pr #${entry.pr}`) ++ if (entry.check) parts.push(String(entry.check)) ++ const at = isoStamp(entry.at) ++ if (at !== '') parts.push(`@ ${at}`) ++ if (parts.length > 0) lines.push(`evidence ${parts.join(' ')}`) ++ } ++ } ++ const chain = [] ++ const seen = new Set() ++ let cursor = task ++ while (cursor !== undefined && cursor !== null && !seen.has(cursor.id)) { ++ seen.add(cursor.id) ++ chain.push(cursor.id) ++ cursor = typeof cursor.reworkOf === 'string' && cursor.reworkOf !== '' ? find(cursor.reworkOf) : undefined ++ } ++ if (chain.length > 1) lines.push(`rework chain ${chain.reverse().join(' -> ')}`) ++ const lease = leaseOf(task) ++ if (lease !== undefined) { ++ lines.push(`lease ${holderName(lease.holder)} @ ${isoStamp(lease.takenAt)} -> ${isoStamp(lease.expiresAt)}`) ++ } ++ return lines ++ } ++ rt.taskAudit = function taskAudit(id) { ++ const task = find(String(id ?? '')) ++ return task === undefined ? '' : auditLine(task).join('\n') ++ } + // The workflow plugin reads a card's lease through the host (#1729), never a copy of its own. + rt.taskLease = function taskLease(id) { + const task = find(String(id ?? '')) @@ -88,7 +138,7 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js const leaseOf = task => (task?.lease !== null && typeof task?.lease === 'object' ? task.lease : undefined) const leaseLive = (task, now = Date.now()) => { const lease = leaseOf(task) -@@ -494,16 +565,28 @@ +@@ -494,16 +615,28 @@ for (const task of Object.values(store())) { const lease = leaseOf(task) if (lease === undefined || !Number.isFinite(lease.expiresAt) || lease.expiresAt > now) continue @@ -122,7 +172,7 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js // A rejection is a handover, not a status flip (#1720): the original's `handoff` names the // roles, and the rework is one child card linked by `reworkOf` — never `parentId`, which // means "part of an overall card" and must never make `settleOverall` fire. A replay of the -@@ -642,6 +725,8 @@ +@@ -642,6 +775,8 @@ // blocked → open is the queue's own edge: a member that cannot finish returns the item, and // no other path sends a card back to the queue, so the edge is not on TRANSITIONS. function requeue(task, reason, retryInSec) { @@ -131,7 +181,7 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js delete task.waitingFor delete task.inputRequest delete task.inputApprovalIds -@@ -828,6 +913,8 @@ +@@ -828,6 +963,8 @@ delete live.queueReason const claimedAt = Date.now() live.lease = { holder: botId, takenAt: claimedAt, expiresAt: claimedAt + LEASE_TTL_SEC * 1000 } @@ -140,7 +190,7 @@ diff --git a/src/host/tasks.js b/src/host/tasks.js transition(live, 'claimed', null, { by: 'claimed from the queue' }) announceClaim(live) await save() -@@ -972,8 +1059,17 @@ +@@ -972,8 +1109,17 @@ if (overall) task.kind = 'group' if (queue) { if (task.claimedBy) throw invalid('That queued item is already claimed; enqueue a new one') diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index f2e1aa941..0187f8711 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -11,6 +11,8 @@ export const PROJECT_BOARD_CSS = ` .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} .bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default} .bt-project-row[data-column="done"]{opacity:.7} +.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} @@ -64,6 +66,7 @@ export function projectBoardView({ project, rows, canManage, tasks } = {}) { nodeId: text(row.nodeId) || null, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, + audit: text(row.audit) || null, board: boardOfRow(row, task), } }), @@ -99,7 +102,11 @@ function ProjectBoardRows({ view, onOpen }) { onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, }, h('span', { className: 'bt-project-column' }, t(row.column)), - h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + h('span', { className: 'bt-project-title-wrap' }, + h('span', { className: 'bt-project-title' }, row.title ?? (row.assignee ? `@${row.assignee}` : '—')), + // The audit text the row already carries (#1730): shown inside the card detail, never a + // second read. A row without one renders exactly as before. + row.audit === null ? null : h('span', { className: 'bt-project-audit' }, row.audit)), row.priority === null ? null : h('span', { className: 'bt-project-priority' }, row.priority), row.runId === null ? null : h('span', { className: 'bt-project-ref' }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)) }))) diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index f2892a425..808b687da 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -54,6 +54,7 @@ export function install(rt) { : runProject?.id === id ? 'workflow' : null if (source === null) continue const fromRun = ref !== undefined && (runProject === null || runProject.id === id) + const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' rows.push({ id: task.id, source, @@ -68,6 +69,10 @@ export function install(rt) { ...(task.handoff && typeof task.handoff === 'object' ? { handoff: task.handoff } : {}), ...(Array.isArray(task.evidence) ? { evidence: task.evidence } : {}), ...(task.lease && typeof task.lease === 'object' ? { lease: task.lease } : {}), + // The same projection the task store renders (#1730): one text block a card detail can + // show, with nothing written back. A pre-slice card answers the empty string and the row + // carries no `audit` key at all. + ...(audit !== '' ? { audit } : {}), ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } diff --git a/patches/ds-bot/files/test/task-audit.test.js b/patches/ds-bot/files/test/task-audit.test.js new file mode 100644 index 000000000..804ad32a9 --- /dev/null +++ b/patches/ds-bot/files/test/task-audit.test.js @@ -0,0 +1,169 @@ +// The audit projection and the rework delivery count (#1730). The audit text is read from the +// typed rows already on the card — handoff, evidence, reworkOf, lease — and rendered twice from +// the same rows identically. A pre-slice record answers the empty string, not a throw. A rework +// chain counts one accepted delivery across two records and a replay cannot double it. + +// The typed Task/Handoff contract (#1720): five additive fields on the one task record — +// `taskClass`, `reworkOf`, `handoff`, `evidence`, `lease` — validated on write and read by the +// Project board from the same row. The rework child is a card linked by `reworkOf` (never +// `parentId`), a lease returns an abandoned queued card to the queue, and a record stored before +// the slice still reads back and renders. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { install as installProjectBoard } from '../src/host/project-board.js' +import { formatRoomBoard } from '../src/host/room-context.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + d2: { id: 'd2', name: 'Dev2', role: 'dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + r1: { id: 'r1', name: 'Rev1', role: 'Reviewer', sessionId: 'r1', createdAt: 4, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd1', 'd2', 'r1'], admin: 'a', mode: 'everyone', named: true, + sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'member', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd1', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd2', role: 'member', displayName: 'Dev2', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'r1', role: 'member', displayName: 'Rev1', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: {}, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function boot(home) { + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const inbox = { + nextStep: [], nextTurn: [], + remove(id) { const keep = message => message.id !== id; this.nextTurn = this.nextTurn.filter(keep); this.nextStep = this.nextStep.filter(keep); return true }, + } + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls: [], status: 'idle', inbox, + followup(message) { this.calls.push(message); this.inbox.nextTurn.push(message) }, + steer() {}, inject() {}, cancel() {}, async whenIdle() {}, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 'made-1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } }) + const idOf = (body, title) => { + const line = body.split('\n').find(item => item.startsWith('- ') && item.includes(title)) + const id = line?.match(/task-[0-9a-f]+/)?.[0] + assert.ok(id, body) + return id + } + const create = async (spec = {}) => { + const body = await track(spec.bot ?? 'd1', { tasks: [{ group: 'War', ...spec }] }) + return { body, id: idOf(body, spec.title) } + } + return { ctx, rt, track, idOf, create, home, agents } +} + +async function start(t, seed = team()) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-contract-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed)) + const app = await boot(home) + app.stop = async () => rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + t.after(async () => { + try { await app.ctx.dispose() } catch {} + await app.stop() + }) + return app +} + +// A restart is the same home and a fresh runtime: the state on disk is what the new process reads. +async function restart(app) { + await app.ctx.dispose() + return boot(app.home) +} + +const childrenOf = (app, originalId) => Object.values(app.rt.state.tasks).filter(task => task.reworkOf === originalId) +const byActor = (task, why) => (task.actors ?? []).filter(entry => entry.by === why) + +test('the audit renders one identical text block from the same rows (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ + title: 'Audit the rework', + handoff: { from: 'Lead', to: 'Dev', at: 1_700_000_000_000, reason: 'needs the test' }, + }) + app.rt.state.tasks[id].evidence = [ + { sha: 'a'.repeat(40), pr: 1730, check: 'tests-ok', at: 1_700_000_100_000 }, + ] + const child = app.rt.state.tasks[id] + const audit = app.rt.taskAudit(id) + assert.match(audit, /^handoff Lead -> Dev: needs the test @ 2023-11-14T22:13:20.000Z$/m) + assert.match(audit, /^evidence a{40} pr #1730 tests-ok @ 2023-11-14T22:15:00.000Z$/m) + assert.equal(audit, app.rt.taskAudit(id), 'the same rows render the same text') + assert.equal(audit, app.rt.taskAudit(child.id), 'the store is read, not consumed') +}) + +test('the audit names the rework chain and the current lease window (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Chain me' }) + app.rt.state.tasks[id].lease = { holder: 'd1', takenAt: 1_700_000_000_000, expiresAt: 1_700_001_800_000 } + const childId = 'task-rework1' + app.rt.state.tasks[childId] = { + id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id, + ownerBotId: 'd1', createdAt: 2, updatedAt: 2, + } + const audit = app.rt.taskAudit(childId) + assert.match(audit, new RegExp('^rework chain ' + id + ' -> ' + childId + '$', 'm')) + assert.match(app.rt.taskAudit(id), /^lease Dev1 @ 2023-11-14T22:13:20.000Z -> 2023-11-14T22:43:20.000Z$/m) +}) + +test('a pre-slice record renders an empty audit, not a throw (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Old card' }) + delete app.rt.state.tasks[id].taskClass + assert.equal(app.rt.taskAudit(id), '') + assert.equal(app.rt.taskAudit('task-nope'), '') +}) + +test('a rework chain counts one accepted delivery and a replay doubles nothing (#1730)', async (t) => { + const app = await start(t) + const { id } = await app.create({ title: 'Ship once' }) + const childId = 'task-rework2' + app.rt.state.tasks[id].status = 'accepted' + app.rt.state.tasks[id].ownerBotId = 'd1' + app.rt.state.tasks[childId] = { + id: childId, title: 'rework', status: 'accepted', taskClass: 'rework', reworkOf: id, + ownerBotId: 'd1', createdAt: 2, updatedAt: 2, + } + const first = app.rt.taskCounters() + assert.equal(first.roles.Dev.accepted, 2, 'two records') + assert.equal(first.roles.Dev.deliveries, 1, 'one accepted delivery for the chain') + // A replay writes the same rows again; the count is a read, so it cannot double. + app.rt.state.tasks[id].status = 'accepted' + app.rt.state.tasks[childId].status = 'accepted' + assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1) + // A rework whose parent never landed is still a delivery of its own. + delete app.rt.state.tasks[id] + assert.equal(app.rt.taskCounters().roles.Dev.deliveries, 1) +}) diff --git a/plugins/workflow/test/strict-release-dag.test.js b/plugins/workflow/test/strict-release-dag.test.js new file mode 100644 index 000000000..b49ed2865 --- /dev/null +++ b/plugins/workflow/test/strict-release-dag.test.js @@ -0,0 +1,76 @@ +// The release path is strict (#1730): build -> test -> publish runs in declared order, a node +// whose required input was never produced is refused before it starts, and a second start after +// a crash between the claim and the result returns the run where it was instead of minting a +// second downstream task. Pure frontier reads only; nothing here starts a Bot. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { completeNode, freshStatus, normalizeGraph, startFrontier, unmetRequired } from '../src/graph.js' + +const releaseGraph = () => normalizeGraph({ + strict: true, + nodes: [ + { id: 'build', name: 'Build', outputs: [{ name: 'artifact' }] }, + { id: 'test', name: 'Test', inputs: [{ name: 'artifact', required: true }], outputs: [{ name: 'report' }] }, + { id: 'publish', name: 'Publish', inputs: [{ name: 'report', required: true }] }, + ], + edges: [ + { from: 'build', to: 'test' }, + { from: 'test', to: 'publish' }, + ], +}) + +test('a strict release graph runs build -> test -> publish in declared order (#1730)', () => { + const graph = releaseGraph() + assert.equal(graph.strict, true) + let status = freshStatus(graph) + const started = [] + let step = startFrontier(graph, status) + assert.deepEqual(step.started, ['build'], 'only the first declared node starts') + started.push(...step.started) + status = step.status + + step = completeNode(graph, status, 'build', { outputs: { artifact: 'dist/app.tgz' } }) + assert.deepEqual(step.started, ['test']) + started.push(...step.started) + status = step.status + + step = completeNode(graph, status, 'test', { outputs: { report: 'tests-ok' } }) + assert.deepEqual(step.started, ['publish']) + started.push(...step.started) + status = step.status + + const done = completeNode(graph, status, 'publish', { outputs: {} }) + assert.deepEqual(done.started, []) + assert.equal(done.finished, true) + assert.deepEqual(started, ['build', 'test', 'publish']) +}) + +test('a node whose required input was never produced is refused (#1730)', () => { + const graph = releaseGraph() + assert.deepEqual(unmetRequired(graph, 'test', {}), ['artifact'], 'no upstream output means no start') + assert.deepEqual(unmetRequired(graph, 'test', { build: { artifact: 'dist/app.tgz' } }), []) + assert.deepEqual(unmetRequired(graph, 'publish', { build: { artifact: 'dist/app.tgz' }, test: { report: 'tests-ok' } }), []) + assert.deepEqual(unmetRequired(graph, 'publish', { build: { artifact: 'dist/app.tgz' } }), ['report']) +}) + +test('a second start after a crash between claim and result adds no downstream task (#1730)', () => { + const graph = releaseGraph() + let status = freshStatus(graph) + let step = startFrontier(graph, status) + status = completeNode(graph, step.status, 'build', { outputs: { artifact: 'dist/app.tgz' } }).status + assert.equal(status.test, 'running', 'test is claimed when the process dies') + + // The restart reads the persisted status: the claimed node is not started a second time and + // the node behind it is not reached early. + const persisted = JSON.parse(JSON.stringify(status)) + const restart = startFrontier(graph, persisted) + assert.deepEqual(restart.started, [], 'a running node is not started again') + assert.equal(restart.status.publish, 'queued', 'publish waits for the claimed result') + + // The claimed result lands once: publish starts once, and a third start sees it running. + const afterResult = completeNode(graph, persisted, 'test', { outputs: { report: 'tests-ok' } }) + assert.deepEqual(afterResult.started, ['publish']) + const again = startFrontier(graph, afterResult.status) + assert.deepEqual(again.started, [], 'no second publish task') + assert.equal(again.status.publish, 'running') +}) From f5f512e0c7c2aa5eb3238dfe0317706d86b688cc Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 04:04:35 +0800 Subject: [PATCH 2/3] Drop the unused test imports and helpers the linter names The audit test is standalone: it does not need the project-board and room-context installs or the restart/byActor helpers copied from the typed-contract test. --- core/test/task-audit.test.js | 10 ---------- patches/ds-bot/files/test/task-audit.test.js | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/core/test/task-audit.test.js b/core/test/task-audit.test.js index 804ad32a9..0aef7e4d9 100644 --- a/core/test/task-audit.test.js +++ b/core/test/task-audit.test.js @@ -14,8 +14,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' -import { install as installProjectBoard } from '../src/host/project-board.js' -import { formatRoomBoard } from '../src/host/room-context.js' import { fakeContext } from './fake-ctx.js' const team = () => ({ @@ -98,14 +96,6 @@ async function start(t, seed = team()) { return app } -// A restart is the same home and a fresh runtime: the state on disk is what the new process reads. -async function restart(app) { - await app.ctx.dispose() - return boot(app.home) -} - -const childrenOf = (app, originalId) => Object.values(app.rt.state.tasks).filter(task => task.reworkOf === originalId) -const byActor = (task, why) => (task.actors ?? []).filter(entry => entry.by === why) test('the audit renders one identical text block from the same rows (#1730)', async (t) => { const app = await start(t) diff --git a/patches/ds-bot/files/test/task-audit.test.js b/patches/ds-bot/files/test/task-audit.test.js index 804ad32a9..0aef7e4d9 100644 --- a/patches/ds-bot/files/test/task-audit.test.js +++ b/patches/ds-bot/files/test/task-audit.test.js @@ -14,8 +14,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime } from '../index.js' -import { install as installProjectBoard } from '../src/host/project-board.js' -import { formatRoomBoard } from '../src/host/room-context.js' import { fakeContext } from './fake-ctx.js' const team = () => ({ @@ -98,14 +96,6 @@ async function start(t, seed = team()) { return app } -// A restart is the same home and a fresh runtime: the state on disk is what the new process reads. -async function restart(app) { - await app.ctx.dispose() - return boot(app.home) -} - -const childrenOf = (app, originalId) => Object.values(app.rt.state.tasks).filter(task => task.reworkOf === originalId) -const byActor = (task, why) => (task.actors ?? []).filter(entry => entry.by === why) test('the audit renders one identical text block from the same rows (#1730)', async (t) => { const app = await start(t) From 9c29b9cc6699709bac1108387a57410ecf910056 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 04:10:10 +0800 Subject: [PATCH 3/3] Document the audit line in the Project board card arch-docs owns core/client.js and src/client/project-board.js through core-client.md, so the audit line is recorded there beside the row mapping it belongs to. --- docs/subsystems/core-client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 28df2f1a6..49b3a4b2f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -277,7 +277,7 @@ by `source` (`bot` | `group` | `workflow`, empty sources left out), carries `col `runId`/`nodeId` a workflow row came from, and keeps `row.id` equal to the underlying task id, so a row never copies a row. The `tasks` list is the ordinary task read joined by `id` (a `Map`, one pass): a row takes its `title`, falls back to `note`, and only then to `@assignee` / `—`, plus its `priority`, so three -cards held by the same person no longer read the same. An empty Project renders the empty state, not an +cards held by the same person no longer read the same. A row that carries the host's audit text (#1730) shows it inside the card detail as `bt-project-audit`, read from that same row with no second fetch; a row without it renders exactly as before. An empty Project renders the empty state, not an error. `canManage` comes from `project-read` and only decides whether the management affordances are drawn; the refusal itself is the host's.