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..0aef7e4d9 --- /dev/null +++ b/core/test/task-audit.test.js @@ -0,0 +1,159 @@ +// 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 { 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 +} + + +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-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. 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..0aef7e4d9 --- /dev/null +++ b/patches/ds-bot/files/test/task-audit.test.js @@ -0,0 +1,159 @@ +// 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 { 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 +} + + +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') +})