From bda2b60b198c954c7a89a0d7975d07ae9d509fc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 06:43:54 +0000 Subject: [PATCH 1/2] Nest a FlyBot task under the issue card it names A task that names an issue already on the board hangs under that card with the same nest markup group subtasks use. A task with no issue, or whose issue is absent, stays its own card. One open record per issue; the host's cardNamesIssue decides which. Fixes #1360 Part of #1265 Co-authored-by: Danyang Chen --- changelog.d/1360-nest-records.changed.md | 1 + core/client.js | 185 +- core/src/client/delivery-view.js | 168 +- core/src/host/tasks.js | 12 +- core/src/host/work-dispatch.js | 23 +- core/src/shared/issue-ref.js | 31 + core/test/delivery-view.test.js | 152 + docs/subsystems/core-client.md | 4 +- docs/subsystems/core-host.md | 3 +- patches/ds-bot/0008-client-bundle.patch | 5233 +++++++++-------- .../ds-bot/files/src/client/delivery-view.js | 168 +- patches/ds-bot/files/src/host/tasks.js | 12 +- .../ds-bot/files/src/host/work-dispatch.js | 23 +- patches/ds-bot/files/src/shared/issue-ref.js | 31 + .../ds-bot/files/test/delivery-view.test.js | 152 + 15 files changed, 3467 insertions(+), 2731 deletions(-) create mode 100644 changelog.d/1360-nest-records.changed.md create mode 100644 core/src/shared/issue-ref.js create mode 100644 patches/ds-bot/files/src/shared/issue-ref.js diff --git a/changelog.d/1360-nest-records.changed.md b/changelog.d/1360-nest-records.changed.md new file mode 100644 index 000000000..06f2ccca1 --- /dev/null +++ b/changelog.d/1360-nest-records.changed.md @@ -0,0 +1 @@ +The board hangs a FlyBot task under the GitHub issue it names, instead of drawing that task as its own card beside the issue (#1360). A task with no issue, or whose issue is not on the board, stays its own card. diff --git a/core/client.js b/core/client.js index 5ad3ec779..ec265c384 100644 --- a/core/client.js +++ b/core/client.js @@ -8441,6 +8441,28 @@ var DELIVERY_CHART_CSS = ` } `; +// src/shared/issue-ref.js +var ISSUE_REF = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi; +function issueRefs(text) { + const keys = []; + for (const match of String(text ?? "").matchAll(new RegExp(ISSUE_REF.source, "gi"))) { + const key = `#${match[1] ?? match[2]}`; + if (!keys.includes(key)) keys.push(key); + } + return keys; +} +function namesIssue(text, issue) { + const number = String(issue ?? "").replace(/^#/, ""); + if (!/^\d+$/.test(number)) return false; + return issueRefs(text).includes(`#${number}`); +} +function cardNamesIssue(task, wanted) { + const own = `${task?.title ?? ""} ${task?.target?.href ?? ""}`; + if (namesIssue(own, wanted)) return true; + return issueRefs(own).length === 0 && namesIssue(task?.note ?? "", wanted); +} +var issueRefPattern = () => new RegExp(ISSUE_REF.source, "gi"); + // src/client/delivery-view.js var import_react16 = require("react"); var POLL_MS3 = 6e4; @@ -8834,41 +8856,154 @@ function BoardFilters({ filter, onFilter }) { } var stageOf = (card, column) => card?.stage ?? column; var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; +var TERMINAL_TASK = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); +var newestTask = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0); +var cardRepoOf = (card, fallback) => { + const own = typeof card?.repo === "string" ? card.repo.trim() : ""; + if (own !== "") return own; + return typeof fallback === "string" ? fallback.trim() : ""; +}; +var recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}`; +function namedRepos(task) { + if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`]; + const text = `${task?.title ?? ""} +${task?.note ?? ""}`; + const repos = []; + for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`); + for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`); + return repos; +} +var repoOk = (task, repo) => { + const named = namedRepos(task); + if (named.length === 0 || repo === "") return true; + return named.includes(repo); +}; +function hangRecords(columns, tasks, repo) { + const issues = []; + for (const cards of Object.values(columns ?? {})) { + for (const card of cards ?? []) { + if (card?.kind !== "issue" || card.number == null) continue; + issues.push({ card, repo: cardRepoOf(card, repo) }); + } + } + const nested = /* @__PURE__ */ new Map(); + const taken = /* @__PURE__ */ new Set(); + const open = (tasks ?? []).filter((task) => task && task.kind !== "group" && !TERMINAL_TASK.has(task.status) && task.id != null); + for (const task of open) { + let best = null; + let bestScore = -1; + const hits = []; + for (const issue of issues) { + if (!repoOk(task, issue.repo)) continue; + const namesIssue2 = cardNamesIssue(task, issue.card.number); + const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked); + if (!namesIssue2 && !namesPull) continue; + hits.push(issue); + } + const bare2 = namedRepos(task).length === 0; + const pool = bare2 && hits.length > 1 ? [] : hits; + for (const issue of pool) { + const target = Number(task.target?.number); + const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0; + if (score > bestScore) { + best = issue; + bestScore = score; + } + } + if (!best) continue; + const key = recordKey(best.repo, best.card.number); + const list = nested.get(key) ?? []; + list.push(task); + nested.set(key, list); + } + const chosen = /* @__PURE__ */ new Map(); + for (const [key, list] of nested) { + const [first, ...rest] = [...list].sort(newestTask); + chosen.set(key, first); + taken.add(first.id); + for (const extra of rest) taken.add(extra.id); + } + return { nested: chosen, loose: (tasks ?? []).filter((task) => task && !taken.has(task.id)) }; +} function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {}; const drawn = placed === null || placed === void 0 ? null : new Set(placed); const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); - const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; - for (const task of filter === "all" ? tasks ?? [] : []) { + const shown = {}; + for (const column of stagesOf(view)) shown[column.id] = deliveryCards(column.id); + const hung = hangRecords(shown, filter === "all" ? tasks ?? [] : [], repo); + const looseByColumn = {}; + for (const task of hung.loose) { const col = taskColumnOf(task); - if (tasksByColumn[col]) tasksByColumn[col].push(task); + (looseByColumn[col] ??= []).push(task); } - const allCards = (id) => { - const delivery = deliveryCards(id); - const taskList = tasksByColumn[id] ?? []; - return [...taskList, ...delivery]; + const taskNode = (task) => (0, import_react16.createElement)(BoardCard2, { + task, + roster, + movable: canMoveTask(roster, task), + onMove, + onDrag + }); + const rowsOf = (id) => { + const cards = shown[id] ?? []; + const linked = /* @__PURE__ */ new Set(); + for (const card of cards) { + if (card?.kind !== "issue" || card.linked == null) continue; + linked.add(recordKey(cardRepoOf(card, repo), card.linked)); + } + const rows = []; + for (const task of looseByColumn[id] ?? []) rows.push((0, import_react16.createElement)(BoardCard2, { + key: task.id, + task, + roster, + movable: canMoveTask(roster, task), + onMove, + onDrag + })); + for (const card of cards) { + const where = cardRepoOf(card, repo); + if (card?.kind === "pr" && linked.has(recordKey(where, card.number))) continue; + const key = `${where}-${card.kind}-${card.number}`; + if (card?.kind === "issue") { + const issueKey = recordKey(where, card.number); + const task = hung.nested.get(issueKey); + if (task) { + rows.push((0, import_react16.createElement)( + "div", + { key: `nest-${issueKey}`, className: "bt-board-nest", "data-issue": issueKey }, + (0, import_react16.createElement)(DeliveryCard, { card, repo: where || null }), + (0, import_react16.createElement)("div", { className: "bt-board-subtask", "data-parent": issueKey }, taskNode(task)) + )); + continue; + } + } + rows.push((0, import_react16.createElement)(DeliveryCard, { key, card, repo: where || null })); + } + return rows; }; - const taskIds = new Set((tasks ?? []).map((task) => task?.id)); - const isTask = (item) => taskIds.has(item?.id); + const drawnCount = (rows) => rows.reduce((count, row) => count + (row?.props?.className === "bt-board-nest" ? 2 : 1), 0); return (0, import_react16.createElement)( "div", { className: "bt-del-columns" }, - stagesOf(view).map((column) => (0, import_react16.createElement)( - "section", - { - key: column.id, - className: "bt-del-column", - "data-column": column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }) - }, - (0, import_react16.createElement)( - "h3", - { className: "bt-board-column-head" }, - (0, import_react16.createElement)("span", null, t(column.label)), - (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) - ), - allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) - )) + stagesOf(view).map((column) => { + const rows = rowsOf(column.id); + return (0, import_react16.createElement)( + "section", + { + key: column.id, + className: "bt-del-column", + "data-column": column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }) + }, + (0, import_react16.createElement)( + "h3", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, t(column.label)), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(drawnCount(rows))) + ), + rows.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : rows + ); + }) ); } function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 5bc6c761a..025dc4949 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -13,7 +13,8 @@ import { createElement as h, useEffect, useState } from 'react' import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' -import { githubCard } from '../shared/board-card.js' +import { githubCard, githubCardKey } from '../shared/board-card.js' +import { cardNamesIssue } from '../shared/issue-ref.js' import { BoardCard, DELIVERY_SKIN } from './board-card.js' import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' @@ -94,7 +95,8 @@ const dropProps = (column, { dragging, onMove, onDrag }) => ({ }, }) -// Simplified task column renderer for degraded states (no GitHub snapshot). +// No issue cards on the board (no token, a failed read, a snapshot that never arrived): every +// record stays a top-level card, including one that names an issue the snapshot does not have. function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = { 'not-started': [], 'in-progress': [], 'done': [] } for (const task of tasks ?? []) { @@ -437,6 +439,85 @@ function BoardFilters({ filter, onFilter }) { const stageOf = (card, column) => card?.stage ?? column const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter +const TERMINAL_TASK = new Set(['accepted', 'cancelled', 'failed']) +const newestTask = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0) + +const cardRepoOf = (card, fallback) => { + const own = typeof card?.repo === 'string' ? card.repo.trim() : '' + if (own !== '') return own + return typeof fallback === 'string' ? fallback.trim() : '' +} + +const recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}` + +// The repository a record already names, read off the same text `cardNamesIssue` matches. +// A bare `#12` names no repository. `target` is the host's normalised pointer and wins over a +// mention in the title, which is how a dispatch and the board stay on one card. +function namedRepos(task) { + if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`] + const text = `${task?.title ?? ''}\n${task?.note ?? ''}` + const repos = [] + for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`) + for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`) + return repos +} + +const repoOk = (task, repo) => { + const named = namedRepos(task) + if (named.length === 0 || repo === '') return true + return named.includes(repo) +} + +// A record hangs under the issue `cardNamesIssue` would hand a dispatch (#1254, #1331). The +// issue's `linked` pull request is the closing-keyword link the snapshot already computed (#1189), +// so a record that names that pull request hangs under the issue too. One open record per issue; +// an older open record that names the same issue is not drawn again. +export function hangRecords(columns, tasks, repo) { + const issues = [] + for (const cards of Object.values(columns ?? {})) { + for (const card of cards ?? []) { + if (card?.kind !== 'issue' || card.number == null) continue + issues.push({ card, repo: cardRepoOf(card, repo) }) + } + } + const nested = new Map() + const taken = new Set() + const open = (tasks ?? []).filter(task => task && task.kind !== 'group' && !TERMINAL_TASK.has(task.status) && task.id != null) + for (const task of open) { + let best = null + let bestScore = -1 + const hits = [] + for (const issue of issues) { + if (!repoOk(task, issue.repo)) continue + const namesIssue = cardNamesIssue(task, issue.card.number) + const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked) + if (!namesIssue && !namesPull) continue + hits.push(issue) + } + // A bare `#12` on a board where two repositories both have that issue names neither. + const bare = namedRepos(task).length === 0 + const pool = bare && hits.length > 1 ? [] : hits + for (const issue of pool) { + const target = Number(task.target?.number) + const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0 + if (score > bestScore) { best = issue; bestScore = score } + } + if (!best) continue + const key = recordKey(best.repo, best.card.number) + const list = nested.get(key) ?? [] + list.push(task) + nested.set(key, list) + } + const chosen = new Map() + for (const [key, list] of nested) { + const [first, ...rest] = [...list].sort(newestTask) + chosen.set(key, first) + taken.add(first.id) + for (const extra of rest) taken.add(extra.id) + } + return { nested: chosen, loose: (tasks ?? []).filter(task => task && !taken.has(task.id)) } +} + // One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {} @@ -446,41 +527,68 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter const deliveryCards = (id) => (columns[id] ?? []) .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) .filter((card) => matchesFilter(card, id, filter)) - - // #1326: Task cards map into the same columns. Map task column to delivery column. - const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } - // A task card names no review or done-today stage, so a stage filter narrows it away rather than - // moving it to a lane it never named (#1326). - for (const task of filter === 'all' ? tasks ?? [] : []) { + + // A stage filter narrows task cards away: a record names no review or done-today stage (#1326). + // Nesting reads every column this view draws, so a record leaves its own column to hang under + // the issue wherever that card sits. A card a workflow section owns is not in `shown`. + const shown = {} + for (const column of stagesOf(view)) shown[column.id] = deliveryCards(column.id) + const hung = hangRecords(shown, filter === 'all' ? tasks ?? [] : [], repo) + const looseByColumn = {} + for (const task of hung.loose) { const col = taskColumnOf(task) - if (tasksByColumn[col]) tasksByColumn[col].push(task) + ;(looseByColumn[col] ??= []).push(task) } - - const allCards = (id) => { - const delivery = deliveryCards(id) - const taskList = tasksByColumn[id] ?? [] - return [...taskList, ...delivery] + const taskNode = (task) => h(TaskBoardCard, { + task, roster, movable: canMoveTask(roster, task), onMove, onDrag, + }) + const rowsOf = (id) => { + const cards = shown[id] ?? [] + // A pull request the issue already links is not a second card in that issue's column. + // A filter that hides the issue (review) still draws the pull request. + const linked = new Set() + for (const card of cards) { + if (card?.kind !== 'issue' || card.linked == null) continue + linked.add(recordKey(cardRepoOf(card, repo), card.linked)) + } + const rows = [] + for (const task of looseByColumn[id] ?? []) rows.push(h(TaskBoardCard, { + key: task.id, task, roster, movable: canMoveTask(roster, task), onMove, onDrag, + })) + for (const card of cards) { + const where = cardRepoOf(card, repo) + if (card?.kind === 'pr' && linked.has(recordKey(where, card.number))) continue + const key = `${where}-${card.kind}-${card.number}` + if (card?.kind === 'issue') { + const issueKey = recordKey(where, card.number) + const task = hung.nested.get(issueKey) + if (task) { + rows.push(h('div', { key: `nest-${issueKey}`, className: 'bt-board-nest', 'data-issue': issueKey }, + h(DeliveryCard, { card, repo: where || null }), + h('div', { className: 'bt-board-subtask', 'data-parent': issueKey }, taskNode(task)))) + continue + } + } + rows.push(h(DeliveryCard, { key, card, repo: where || null })) + } + return rows } - - // The two kinds share these columns, so which one a card is has to come from where it was read, - // never from guessing at its fields. - const taskIds = new Set((tasks ?? []).map(task => task?.id)) - const isTask = (item) => taskIds.has(item?.id) - + const drawnCount = (rows) => rows.reduce((count, row) => count + (row?.props?.className === 'bt-board-nest' ? 2 : 1), 0) + return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }), - }, + stagesOf(view).map(column => { + const rows = rowsOf(column.id) + return h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }), + }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), - allCards(column.id).length === 0 + h('span', { className: 'bt-board-count' }, String(drawnCount(rows)))), + rows.length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : allCards(column.id).map(item => - isTask(item) - ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) + : rows) + })) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index d7891f38e..224ba741a 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -18,7 +18,7 @@ import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, sameRole } from '../shared/task-board.js' import { currentParticipants, deriveParticipants } from './room-membership.js' -import { issueRefs, namesIssue } from './work-dispatch.js' +import { cardNamesIssue } from '../shared/issue-ref.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -500,16 +500,6 @@ export function install(rt) { } } - // #1331: a card belongs to the issue its own fields name. `title`/`target` name it; a note that - // merely mentions the id ("blocked on #1234 first") does not, or a dispatch for #1234 rewrites the - // #999 card and leaves #1234 with no card at all. The note is still read for a card that names no - // issue anywhere else, so #1254's "one issue is one card" holds for cards written from a note. - const cardNamesIssue = (task, wanted) => { - const own = `${task.title ?? ''} ${task.target?.href ?? ''}` - if (namesIssue(own, wanted)) return true - return issueRefs(own).length === 0 && namesIssue(task.note ?? '', wanted) - } - // #1254: the room's task lead naming one member with an issue id and an imperative is an assign, // not a round (`groups.js` `dispatchOf`). The card that member already holds for that issue is // updated; otherwise one is opened, owned by that member and still `open` — the host assigns, and diff --git a/core/src/host/work-dispatch.js b/core/src/host/work-dispatch.js index 636fcbf54..0f9e75f19 100644 --- a/core/src/host/work-dispatch.js +++ b/core/src/host/work-dispatch.js @@ -3,7 +3,10 @@ // that member's card and the drive wakes it in its own chat (#1205); no group part opens. A // question, a remark, a line naming two members or an `@all` keeps the group turn it had — this // module only answers the shape of one line, the room decides who may dispatch. -const ISSUE = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi +import { issueRefPattern } from '../shared/issue-ref.js' + +export { cardNamesIssue, issueRefs, namesIssue } from '../shared/issue-ref.js' + const MENTION = /@[^\s@,,。]{1,40}/g const QUESTION = /[??]/ // An imperative counts only near the id: "rebase #1234" and "#1234 rebase and push" both dispatch, @@ -16,22 +19,6 @@ const oneLine = value => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' // knows who was named. const withoutMentions = text => String(text ?? '').replace(MENTION, ' ').replace(/\s+/g, ' ').trim() -// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. -export function issueRefs(text) { - const keys = [] - for (const match of String(text ?? '').matchAll(new RegExp(ISSUE.source, 'gi'))) { - const key = `#${match[1] ?? match[2]}` - if (!keys.includes(key)) keys.push(key) - } - return keys -} - -export function namesIssue(text, issue) { - const number = String(issue ?? '').replace(/^#/, '') - if (!/^\d+$/.test(number)) return false - return issueRefs(text).includes(`#${number}`) -} - // One member, one issue id, one imperative near it. Anything else is not this module's to convert. export function workDispatch(text, { callees = [] } = {}) { if (!Array.isArray(callees) || callees.length !== 1) return undefined @@ -39,7 +26,7 @@ export function workDispatch(text, { callees = [] } = {}) { if (!callee?.id) return undefined const line = withoutMentions(text) if (line === '' || QUESTION.test(line)) return undefined - for (const match of line.matchAll(new RegExp(ISSUE.source, 'gi'))) { + for (const match of line.matchAll(issueRefPattern())) { const near = line.slice(Math.max(0, match.index - NEAR), match.index + match[0].length + NEAR) if (!IMPERATIVE.test(near)) continue const issue = `#${match[1] ?? match[2]}` diff --git a/core/src/shared/issue-ref.js b/core/src/shared/issue-ref.js new file mode 100644 index 000000000..ee1cbd167 --- /dev/null +++ b/core/src/shared/issue-ref.js @@ -0,0 +1,31 @@ +// One way to read an issue or pull-request number out of text (#1254, #1331, #1360). +// The dispatch, the task store and the board all call these. A second pattern would +// hang a record the host does not treat as that issue's card. + +const ISSUE_REF = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi + +// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. +export function issueRefs(text) { + const keys = [] + for (const match of String(text ?? '').matchAll(new RegExp(ISSUE_REF.source, 'gi'))) { + const key = `#${match[1] ?? match[2]}` + if (!keys.includes(key)) keys.push(key) + } + return keys +} + +export function namesIssue(text, issue) { + const number = String(issue ?? '').replace(/^#/, '') + if (!/^\d+$/.test(number)) return false + return issueRefs(text).includes(`#${number}`) +} + +// A card belongs to the issue its own fields name. `title` and `target` name it; a note +// that merely mentions the id does not, unless the card names no issue anywhere else. +export function cardNamesIssue(task, wanted) { + const own = `${task?.title ?? ''} ${task?.target?.href ?? ''}` + if (namesIssue(own, wanted)) return true + return issueRefs(own).length === 0 && namesIssue(task?.note ?? '', wanted) +} + +export const issueRefPattern = () => new RegExp(ISSUE_REF.source, 'gi') diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index 6926a01ab..ea2dbb171 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -860,3 +860,155 @@ test('a workflow draws its own columns, named by the workflow, and its card once const ready = legacy.find(node => node.props['data-column'] === 'ready') assert.equal(walk(ready).some(node => node.props?.['data-number'] === 21), false) }) + +// #1360: a direct child of the column is a top-level card. A nested record sits inside +// `bt-board-nest` and must not count as a sibling of the issue. +const topLevel = (column) => (column?.children ?? []).filter(node => + node && (node.type === 'article' || node.props?.className === 'bt-board-nest')) +const columnOf = (board, id) => walk(board).find(node => node.type === 'section' && node.props?.['data-column'] === id && node.props['data-workflow'] === undefined) + +const issueCard = (repo, number, extra = {}) => ({ + kind: 'issue', number, title: `Issue ${number}`, labels: ['agent-working'], repo, + url: `https://github.com/${repo}/issues/${number}`, ...extra, +}) +const pullCard = (repo, number) => ({ + kind: 'pr', number, title: `PR ${number}`, labels: [], repo, stage: 'review', badge: 'inc', + url: `https://github.com/${repo}/pull/${number}`, sha: 'abcdef1', +}) + +test('#1360: a task that names an issue hangs under that card, and the linked PR is not a third sibling', () => { + const moved = [] + const tasks = [ + { id: 'task-new', title: 'acme/board#12 implement', status: 'working', ownerBotId: 'bot', updatedAt: 5, note: 'in flight' }, + { id: 'task-old', title: '#12 the earlier card', status: 'working', ownerBotId: 'bot', updatedAt: 1 }, + { id: 'task-note', title: 'follow up', note: 'see #12', status: 'claimed', ownerBotId: 'bot', updatedAt: 4 }, + { id: 'task-other', title: '#99 elsewhere', note: 'blocked on #12 first', status: 'open', ownerBotId: 'bot' }, + { id: 'task-orphan', title: 'Write the weekly note', status: 'open', ownerBotId: 'bot' }, + ] + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks, + roster: { byId: { bot: { name: 'Dev4' } } }, + onMove: (id, column) => moved.push([id, column]), + }) + const progress = columnOf(board, 'in-progress') + const rows = topLevel(progress) + assert.equal(rows.length, 1, 'one top-level row: the issue, not the task and the PR beside it') + assert.equal(rows[0].props.className, 'bt-board-nest') + const issue = walk(rows[0]).find(node => node.props?.['data-number'] === 12) + assert.equal(issue.props['data-kind'], 'issue') + assert.equal(issue.props['data-key'], 'acme/board#12') + const sub = walk(rows[0]).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(sub.props['data-parent'], 'acme/board#12') + const hung = walk(sub).find(node => node.props?.['data-task']) + // Newest open record that names the issue. The older open card is not drawn again, and a note + // that names #12 does not beat a title that names no issue of its own when another card does — + // task-note's own fields name nothing, so it also matches, but it is older than task-new. + assert.equal(hung.props['data-task'], 'task-new') + assert.equal(walk(board).filter(node => node.props?.['data-task'] === 'task-old').length, 0, 'the older open card is not a second record') + assert.equal(walk(board).some(node => node.props?.['data-number'] === 41 && node.type === 'article' && !walk(rows[0]).includes(node)), false) + assert.equal(topLevel(progress).some(node => node.props?.['data-number'] === 41), false, 'the linked PR is not a sibling') + assert.match(text(hung), /Dev4/) + const complete = walk(sub).find(node => node.type === 'button' && text(node).includes('Complete')) + assert.ok(complete, 'the nested record still offers Complete') + complete.props.onClick() + assert.deepEqual(moved, [['task-new', 'done']]) + // The orphan stays its own card. A title that names a different issue, with #12 only in the note, + // is not this issue's record (#1331) and stays top-level because #99 is not on the board. + const todo = columnOf(board, 'not-started') + const loose = topLevel(todo).map(node => node.props?.['data-task']).filter(Boolean).sort() + assert.deepEqual(loose, ['task-orphan', 'task-other']) + assert.equal(walk(todo).some(node => node.props?.className === 'bt-board-subtask'), false) + + // Deleting the issue link puts the record back beside the issue. The linked PR stays folded + // into the issue, so the column's top-level count is the issue plus the task. + const unlinked = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks: [{ id: 'task-free', title: 'implement the step', status: 'working', ownerBotId: 'bot' }], + roster: { byId: { bot: { name: 'Dev4' } } }, + }) + const again = columnOf(unlinked, 'in-progress') + assert.equal(topLevel(again).length, 2) + assert.equal(walk(again).some(node => node.props?.className === 'bt-board-subtask'), false) + assert.equal(walk(again).some(node => node.props?.['data-task'] === 'task-free'), true) + assert.equal(topLevel(again).some(node => node.props?.['data-number'] === 41), false) + + // A record that names the pull request hangs under the issue that pull request closes (#1189). + const viaPull = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks: [{ id: 'task-pr', title: 'review #41', status: 'working', ownerBotId: 'bot' }], + }) + const under = walk(columnOf(viaPull, 'in-progress')).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(under?.props['data-parent'], 'acme/board#12') + assert.equal(walk(under).find(node => node.props?.['data-task'])?.props['data-task'], 'task-pr') + assert.equal(topLevel(columnOf(viaPull, 'in-progress')).length, 1) +}) + +test('#1360: a bare number nests only when one issue on the board has it; a named repo nests on that repo', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: null, asOf: '2026-10-27T06:00:00Z', + repos: [ + { repo: 'acme/one', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/one', 12)], done: [] } }, + { repo: 'acme/two', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/two', 12)], done: [] } }, + ], + }, + tasks: [ + { id: 'task-bare', title: '#12 ambiguous', status: 'working' }, + { id: 'task-one', title: 'land acme/one#12', status: 'working' }, + ], + }) + const progress = columnOf(board, 'in-progress') + const subs = walk(progress).filter(node => node.props?.className === 'bt-board-subtask') + assert.equal(subs.length, 1) + assert.equal(subs[0].props['data-parent'], 'acme/one#12') + assert.equal(walk(subs[0]).find(node => node.props?.['data-task']).props['data-task'], 'task-one') + assert.equal(walk(progress).filter(node => node.props?.['data-task'] === 'task-bare').length, 1, 'a bare #12 with two issues stays top-level') + assert.equal(walk(subs).some(node => node.props?.['data-task'] === 'task-bare'), false) +}) + +test('#1360: with no issue on the board, an orphan and a linked task both stay top-level', () => { + const tasks = [ + { id: 'task-orphan', title: 'Write the weekly note', status: 'open' }, + { id: 'task-linked', title: 'acme/board#12 do it', status: 'working', target: { owner: 'acme', repo: 'board', number: 12, href: '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/acme/board/issues/12' } }, + ] + const failed = DeliveryBoard({ + view: { state: 'failed', repo: 'acme/board', error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null }, retryAt: Date.now() + 10_000 }, + tasks, + onRetry: () => {}, + }) + const ids = walk(failed).filter(node => node.props?.['data-task']).map(node => node.props['data-task']).sort() + assert.deepEqual(ids, ['task-linked', 'task-orphan']) + assert.equal(walk(failed).some(node => node.props?.className === 'bt-board-subtask'), false) + // The issue the task names is not in this snapshot, so the record is a card, not a blank. + const missing = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { 'not-started': [issueCard('acme/board', 8)], 'in-progress': [], done: [] }, + }, + tasks: [tasks[1]], + }) + assert.equal(walk(missing).some(node => node.props?.className === 'bt-board-subtask'), false) + assert.equal(walk(missing).filter(node => node.props?.['data-task'] === 'task-linked').length, 1) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d76e5e143..db04ce964 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,11 +38,11 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | -| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | +| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). #1360 hangs the open FlyBot record under the issue it names (`hangRecords`, the same `cardNamesIssue` predicate as a dispatch): the issue row is `div.bt-board-nest` and the record is `div.bt-board-subtask[data-parent=owner/repo#N]`, the same indent a group subtask uses. At most one open record per `owner/repo#N` (the newest). A record with no issue link, a bare `#N` that two repositories share, or an issue that is not on the board stays a top-level card. A pull request the issue's `linked` field already names is not a second card in that column; a stage filter that hides the issue still draws the pull request. With no columns (no token, a failed read) every record stays top-level. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | | `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` — the same key for an issue and the pull request that closes it, which is the identity step 3 hangs a FlyBot record under — and `taskCardKey(id)` is `task:`. `githubCard(row, repo)` and `taskCard(task)` build the model the component draws, `sameCard(a, b)` is the one comparison both sides dedupe by, and a GitHub row with no repository falls back to `github::` rather than imply an identity across repositories. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog is one board, not two tabs (#1326): `UnifiedBoard` keeps `boardRail` on the left and draws task cards and GitHub cards in the same columns through `DeliveryView`, so the rail only picks the task scope — the GitHub side stays repository-wide. A task card on that board is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog is one board, not two tabs (#1326): `UnifiedBoard` keeps `boardRail` on the left and draws task cards and GitHub cards in the same columns through `DeliveryView`, so the rail only picks the task scope — the GitHub side stays repository-wide. A record that names an issue already on that board is nested under the issue card rather than drawn beside it (#1360, in `delivery-view.js`). A task card on that board is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3ce5a21fe..314c2b24a 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -53,6 +53,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` and `taskCardKey(id)` is `task:`, the two spellings the client's `BoardCard` puts on every card. The GitHub key is not keyed by kind — an issue and the pull request that closes it are one card — which is the identity step 3 hangs a FlyBot task record under; a row with no repository falls back to `github::` rather than imply a cross-repository identity. `githubCard(row, repo)` / `taskCard(task)` build the model, `sameCard(a, b)` is the one comparison. Tests: `core/test/board-card.test.js`. | +| `core/src/shared/issue-ref.js` | The one issue/PR matcher (#1254, #1331, #1360): `issueRefs`, `namesIssue` and `cardNamesIssue`. Own fields (`title`, `target.href`) name the card; a note counts only when those name no issue. `work-dispatch.js` re-exports them and `tasks.js` `assignFromDispatch` uses `cardNamesIssue` to pick the newest open record. The board calls the same function, so a nested record is the card the host would update. Tests: `core/test/work-dispatch.test.js`, `core/test/delivery-view.test.js`. | | `core/src/shared/workflow.js` | The workflow engine's pure half (#1272): a workflow is data — `nodes` (each with a required `role` and typed `inputs` / `outputs`), `edges` (`. -> .`) and declared `loops` — and every function below is a function of that file. `validateWorkflow` refuses each way a definition cannot run, with its own sentence (an input nothing produces, an output nothing consumes and that is not a `deliverable`, two producers of one fact, an undeclared cycle, a node with no `role`, a role not on the team, an unknown artifact kind, a misaligned edge, a guard whose `is` is not one of `present` / `fresh` / `equals`, a `column` the board does not have); `workflowColumns` is the board's columns in dependency order, so the node ids are the state and no built-in list exists, and a node carrying `column: false` draws no column at all while a definition that states none keeps the node id as its column (#1265); `layoutWorkflow` is the flowchart (one box per node carrying its role and rails, one edge per `edges[]` row, ranks left to right); `enterNode` / `inputStates` / `renderRefusal` answer whether a card may start and, when it may not, say which input is missing or stale with both revisions, in the node's own `refusal` wording when it has one and in the guard's own wording when that guard carries one; both read a fact by the same parsed key the rest of the engine uses, so the spelling on the card and the spelling on the producer cannot disagree (#1298); `holderOf` is the node's role, which is the card's assignee. #1291's four debts are closed here: one word for one thing (`role`, never `bot`), an unknown guard `is` refuses instead of being read as `present`, `producersOf` / `consumersOf` key on the parsed spec so `text: draft` and `text:draft` are one fact, and `resolvedEdges` reports the artifact it carries once. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-blank.js` | The `blank` preset (#1272): three nodes (`draft` / `review` / `publish`, roles `Writer` / `Editor` / `Publisher`), no guards, artifacts `text:topic -> text:draft -> text:edited -> link:published`. Data, not logic — the same JSON is what a workspace `workflows/blank.json` holds — and the proof that the engine special-cases no node id. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-repo-dev.js` | The `repo-dev` preset (#1272 step 2): one repository change as data — `triage` → `build` → `verify` → `review` → `gate` → `merge` → `release`, roles `Triage` / `Dev` / `SelfTest` / `Reviewer` / `system` / `Release`, and the artifacts `issue` → `issue.ready` → `pr` → `check:tests-ok` → `verdict` → `risk:tier` → `merge-commit` → `release`. Two roles say what they are rather than who: `verify` is `SelfTest` **and** CI, the run being the `check:tests-ok` fact it hands out, and `gate` is held by the Reviewer that judged the pull request, read from the verdict's own `Written-by` through a guard. The gate's guards refuse in the merge gate's own words against `.github/scripts/fixtures/workflow-cases.json`, which no file imports and both sides read. `column` per node: `not-started` / `in-progress` / `done`, with `review` on `false` — a review is the pull request's state, not a third lane (#1265). Tests: `core/test/workflow-repo-dev.test.js`. | @@ -206,7 +207,7 @@ A member's own line calls the members it names in two shapes where the call used ### A dispatch is a board assign (#1254) -An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and push` opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. Now the line is the assignment. `work-dispatch.js` `workDispatch(text, { callees })` answers only the shape — exactly one named member, an issue/PR id (`#1234` or a `github.com///(issues|pull)/` link), an imperative within 40 characters of that id (English verbs, or Chinese ones such as 修 / 改 / 推 / 补), and no `?` — and `groups.js` `dispatchOf` adds the room's rules: a Bot's own post, no `@all`, not quiet or an event, a room with a board, and the poster is `rt.taskLeadOf(room)` (the picked lead, else the admin). `rt.assignFromDispatch` in `tasks.js` then updates the open card that member already holds for that issue (matched on `title` or `target`; a card whose own fields name no issue is matched on its `note`, so a note that merely mentions another issue no longer reclaims it — #1331) or opens one owned by it, still `open` — the host assigns, and the member claims on its own turn, where #1253's claim line is posted in its own voice. The save runs the drive, so the member is woken in its own chat (#1205). A card that is not driving — `result-ready` or `blocked` — is restarted through an edge the state machine already has (`result-ready → working`, `blocked → working`), because `task-drive` wakes only `open`/`claimed`/`working` and rewriting the note alone would leave the member unwoken; a card blocked on an input is reused rather than duplicated, so one issue stays one card, and a stored card with no `actors` list has one started instead of throwing after the note moved. The relay's `speakers` is empty for that opener and the round records no part or pass; the same dispatch posted while a round runs is written from `showInRound` and adds no `round.calls`, so the member is not called twice — but a card write that answers `undefined` is never a silent drop: the opener's `speakers` fall back to the ordinary ones and a mid-round dispatch falls back to `round.calls`, so the member is called in the round instead of not at all. A question, a remark with no imperative, two named members, `@all`, a poster that does not divide the board, and a face-to-face room (no board) keep the group turn they had. The line is still in the room log, and the card shows on the board. Tests: `core/test/work-dispatch.test.js`. +An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and push` opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. Now the line is the assignment. `work-dispatch.js` `workDispatch(text, { callees })` answers only the shape — exactly one named member, an issue/PR id (`#1234` or a `github.com///(issues|pull)/` link), an imperative within 40 characters of that id (English verbs, or Chinese ones such as 修 / 改 / 推 / 补), and no `?` — and `groups.js` `dispatchOf` adds the room's rules: a Bot's own post, no `@all`, not quiet or an event, a room with a board, and the poster is `rt.taskLeadOf(room)` (the picked lead, else the admin). `rt.assignFromDispatch` in `tasks.js` then updates the open card that member already holds for that issue (`cardNamesIssue` in `src/shared/issue-ref.js`, re-exported from `work-dispatch.js`: matched on `title` or `target`; a card whose own fields name no issue is matched on its `note`, so a note that merely mentions another issue no longer reclaims it — #1331; the board calls that same function, #1360) or opens one owned by it, still `open` — the host assigns, and the member claims on its own turn, where #1253's claim line is posted in its own voice. The save runs the drive, so the member is woken in its own chat (#1205). A card that is not driving — `result-ready` or `blocked` — is restarted through an edge the state machine already has (`result-ready → working`, `blocked → working`), because `task-drive` wakes only `open`/`claimed`/`working` and rewriting the note alone would leave the member unwoken; a card blocked on an input is reused rather than duplicated, so one issue stays one card, and a stored card with no `actors` list has one started instead of throwing after the note moved. The relay's `speakers` is empty for that opener and the round records no part or pass; the same dispatch posted while a round runs is written from `showInRound` and adds no `round.calls`, so the member is not called twice — but a card write that answers `undefined` is never a silent drop: the opener's `speakers` fall back to the ordinary ones and a mid-round dispatch falls back to `round.calls`, so the member is called in the round instead of not at all. A question, a remark with no imperative, two named members, `@all`, a poster that does not divide the board, and a face-to-face room (no board) keep the group turn they had. The line is still in the room log, and the card shows on the board. Tests: `core/test/work-dispatch.test.js`. ### Team state and access diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e3a361516..0991e946e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1866,7 +1866,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,7183 @@ function usageOf(list, ids) { +@@ -1624,835 +1624,1788 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -3580,40 +3580,6 @@ diff --git a/client.js b/client.js -.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} -.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} -.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} --.bt-states{display:flex;flex-wrap:wrap;gap:4px} --.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} --.bt-state:hover{color:var(--bt-ink)} --.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} --.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} --.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} --body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} --body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} --.bt-tabs{flex-wrap:wrap} --.bt-soft:disabled{opacity:.4;cursor:default} --.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} --.bt-members{display:flex;flex-direction:column;gap:1px} --.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} --.bt-member:hover{background:var(--bt-hover)} --.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} --.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} --.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} --.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} --.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} --.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} --.bt-add{color:var(--bt-ink-2);margin-top:2px} --.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} --.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} --.bt-motion-seg{width:216px} --.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} --.bt-seg button:disabled{opacity:.4;cursor:default} --.bt-seg button:active:not(:disabled){scale:.96} --.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} --.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} --@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} +// src/client/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -3817,92 +3783,7 @@ diff --git a/client.js b/client.js + }, [value]); + return [value || kept, !value && Boolean(kept)]; +} - --/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ --.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} --.bt-mem-row:hover{background:var(--bt-hover)} --.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} --.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.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} --.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} --.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} --.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} --.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} --.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} --.bt-mem-head-gap{flex:1} --.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} --.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} --.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} --.bt-mem-summary{transition:opacity .3s ease} --.bt-mem-summary[data-stale]{opacity:.55} --.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} --.bt-mem-summary h3:first-child{margin-top:0} --.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} --.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-summary>:nth-child(2){animation-delay:.03s} --.bt-mem-summary>:nth-child(3){animation-delay:.06s} --.bt-mem-summary>:nth-child(4){animation-delay:.09s} --.bt-mem-summary>:nth-child(5){animation-delay:.12s} --.bt-mem-summary>:nth-child(6){animation-delay:.15s} --.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} --@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} --.bt-mem-skel{display:flex;flex-direction:column;gap:24px} --.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} --.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} --.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} --@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} --.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} --.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} --.bt-mem-empty-mark svg{width:22px;height:22px} --.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} --.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} --.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} --.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} --.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} --.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} --.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} --.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} --.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} --.bt-mem-entry[data-busy]{opacity:.4} --.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} --.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} --.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} --.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} --.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} --@media (hover:none){.bt-mem-remove{opacity:1}} --.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} --.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} --.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} --.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} --.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} --.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} --.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} --.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} --.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} --.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} --.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-mem-input::placeholder{color:var(--bt-ink-3)} --.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mem-send:disabled{opacity:.22;cursor:default} --.bt-mem-send:not(:disabled):active{scale:.92} --.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} --@keyframes bt-mem-spin{to{transform:rotate(360deg)}} --@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} --@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} ++ +// src/client/styles.js +var SHELL_CSS = ` +body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} @@ -4012,27 +3893,7 @@ diff --git a/client.js b/client.js +.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} +.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} +.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} --.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} --.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} --.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} --:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} --:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} --@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} ++ +.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} @@ -4592,41 +4453,98 @@ diff --git a/client.js b/client.js +.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} +.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} +.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} -+.bt-states{display:flex;flex-wrap:wrap;gap:4px} -+.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} -+.bt-state:hover{color:var(--bt-ink)} -+.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} -+.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} -+.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} -+body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} -+body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} -+.bt-tabs{flex-wrap:wrap} -+.bt-soft:disabled{opacity:.4;cursor:default} -+.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} -+.bt-members{display:flex;flex-direction:column;gap:1px} -+.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} -+.bt-member:hover{background:var(--bt-hover)} -+.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} -+.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} -+.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} -+.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} -+.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} -+.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} -+.bt-add{color:var(--bt-ink-2);margin-top:2px} -+.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} -+.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} -+.bt-motion-seg{width:216px} -+.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} -+.bt-seg button:disabled{opacity:.4;cursor:default} -+.bt-seg button:active:not(:disabled){scale:.96} -+.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} -+.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} -+@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} -+@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} -+ + .bt-states{display:flex;flex-wrap:wrap;gap:4px} + .bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} + .bt-state:hover{color:var(--bt-ink)} +@@ -2488,2292 +2488,5501 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} + @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} + @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} + +-/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ +-.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} +-.bt-mem-row:hover{background:var(--bt-hover)} +-.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} +-.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.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} +-.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} +-.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} +-.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} +-.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} +-.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} +-.bt-mem-head-gap{flex:1} +-.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} +-.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} +-.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} +-.bt-mem-summary{transition:opacity .3s ease} +-.bt-mem-summary[data-stale]{opacity:.55} +-.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} +-.bt-mem-summary h3:first-child{margin-top:0} +-.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} +-.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-summary>:nth-child(2){animation-delay:.03s} +-.bt-mem-summary>:nth-child(3){animation-delay:.06s} +-.bt-mem-summary>:nth-child(4){animation-delay:.09s} +-.bt-mem-summary>:nth-child(5){animation-delay:.12s} +-.bt-mem-summary>:nth-child(6){animation-delay:.15s} +-.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} +-@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} +-.bt-mem-skel{display:flex;flex-direction:column;gap:24px} +-.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} +-.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} +-.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} +-@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} +-.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} +-.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} +-.bt-mem-empty-mark svg{width:22px;height:22px} +-.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} +-.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} +-.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} +-.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} +-.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} +-.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} +-.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} +-.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +-.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} +-.bt-mem-entry[data-busy]{opacity:.4} +-.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +-.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} +-.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +-.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} +-.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} +-.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} +-@media (hover:none){.bt-mem-remove{opacity:1}} +-.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} +-.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} +-.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +-.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} +-.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} +-.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} +-.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} +-.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} +-.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} +-.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} +-.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} +-.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} +-.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} +-.bt-mem-input::placeholder{color:var(--bt-ink-3)} +-.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mem-send:disabled{opacity:.22;cursor:default} +-.bt-mem-send:not(:disabled):active{scale:.92} +-.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} +-@keyframes bt-mem-spin{to{transform:rotate(360deg)}} +-@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} +-@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} +/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ +.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} +.bt-mem-row:hover{background:var(--bt-hover)} @@ -5284,66 +5202,167 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("rect", { className: "bt-flybot-body", x: 248, y: 104, width: 16, height: 34 }) + ); +} -+ -+// src/client/questions.js -+var import_react6 = require("react"); -+var keyLetter = (index) => String.fromCharCode(65 + index); -+var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); -+var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; -+function useOptionKeys(count, onPick, ownRef) { -+ const pick2 = (0, import_react6.useRef)(onPick); -+ pick2.current = onPick; -+ (0, import_react6.useEffect)(() => { -+ 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; -+ if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -+ if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -+ const index = event.key.toUpperCase().charCodeAt(0) - 65; -+ if (index >= 0 && index < count) { -+ event.preventDefault(); -+ pick2.current(index); -+ } else if (index === count && ownRef.current) { -+ event.preventDefault(); -+ ownRef.current.focus(); -+ } -+ }; -+ document.addEventListener("keydown", onKey, true); -+ return () => document.removeEventListener("keydown", onKey, true); -+ }, [count, ownRef]); -+} -+function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -+ const [own, setOwn] = (0, import_react6.useState)(""); -+ const [picked, setPicked] = (0, import_react6.useState)([]); -+ const ownRef = (0, import_react6.useRef)(null); -+ const titleId = (0, import_react6.useId)(); -+ const options = optionsOf(question); -+ const multi = question.multiSelect === true; -+ const custom = own.trim(); -+ const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -+ const choose = (label) => { -+ if (multi) { -+ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -+ return; -+ } -+ onAnswer({ selected: [label] }); -+ }; -+ const submit = () => { -+ if (!canSubmit) return; -+ onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -+ }; -+ useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -+ const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -+ const field2 = ownAnswer ? (0, import_react6.createElement)("textarea", { -+ ref: ownRef, -+ rows: 1, -+ value: own, -+ autoComplete: "off", -+ spellCheck: false, -+ className: "bt-q-input", -+ "aria-label": t("Your own answer"), -+ placeholder: t("Type your own answer"), -+ "aria-keyshortcuts": ownLetter?.toLowerCase(), + +-.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} +-.bt-thumb[data-hidden]{opacity:0} +-[data-morph]{overflow:hidden!important} +-.bt-veil{animation:bt-veil-in .22s ease-out} +-.bt-with-icon{display:inline-flex;align-items:center;gap:4px} +-.bt-with-icon svg{width:14px;height:14px} +-.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} +-.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} +-.bt-create-inline .bt-create-card{max-width:none} +-.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} +-.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} +-.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} +-.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} +-:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} +-:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} +-@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} +-`; +-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%)} +-.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} +-.bt-think summary::-webkit-details-marker{display:none} +-.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} +-.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} +-.bt-event button:hover{background:var(--bt-hover)} +-.bt-event button:active{background:var(--bt-active)} +-.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} +-.bt-event-whole>*{flex:none} +-.bt-event-whole>span:first-child{margin-right:2px} +-/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ +-.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} +-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} +-.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 +- mode folds away in a finished turn stays shown. TurnProcessCell also opens that +- fold; these rules cover the first frame before it does. */ +-[data-step-process]>div:first-child{display:none!important} +-[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} +-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} +-/* The transcript runs under a floating header pill. */ +-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} +-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} +-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} +-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +-[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} +-/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), +- without its copy and branch row; a tail with nothing in it takes no room. */ +-[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} +-[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} +-/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not +- the shell's new-session hero: no headline, no workspace or mode picker, and the +- composer stays docked at the bottom. */ +-[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +-[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} +-/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ +-[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} +-/* The shell's running status only hosts the activity row (.bt-typing). */ +-[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} +-.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} +-.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} +-.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))} +-.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} +-.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} +-.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} +-.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} +-.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} +-@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} +-@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} +-@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} +-/* Shimmer: a stepped sweep clipped to the text. */ +-.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} +-@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} +-/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ +-[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} +-@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} +-@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} +-@media (prefers-reduced-motion:reduce){ +-.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} +-.bt-typing[data-exiting]{animation-name:bt-fade-out} +-.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} +-.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} +-[data-bt-enter="new"]{animation:none} +-[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} ++// src/client/questions.js ++var import_react6 = require("react"); ++var keyLetter = (index) => String.fromCharCode(65 + index); ++var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); ++var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; ++function useOptionKeys(count, onPick, ownRef) { ++ const pick2 = (0, import_react6.useRef)(onPick); ++ pick2.current = onPick; ++ (0, import_react6.useEffect)(() => { ++ 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; ++ if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; ++ if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; ++ const index = event.key.toUpperCase().charCodeAt(0) - 65; ++ if (index >= 0 && index < count) { ++ event.preventDefault(); ++ pick2.current(index); ++ } else if (index === count && ownRef.current) { ++ event.preventDefault(); ++ ownRef.current.focus(); ++ } ++ }; ++ document.addEventListener("keydown", onKey, true); ++ return () => document.removeEventListener("keydown", onKey, true); ++ }, [count, ownRef]); ++} ++function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { ++ const [own, setOwn] = (0, import_react6.useState)(""); ++ const [picked, setPicked] = (0, import_react6.useState)([]); ++ const ownRef = (0, import_react6.useRef)(null); ++ const titleId = (0, import_react6.useId)(); ++ const options = optionsOf(question); ++ const multi = question.multiSelect === true; ++ const custom = own.trim(); ++ const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; ++ const choose = (label) => { ++ if (multi) { ++ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); ++ return; ++ } ++ onAnswer({ selected: [label] }); ++ }; ++ const submit = () => { ++ if (!canSubmit) return; ++ onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); ++ }; ++ useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); ++ const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; ++ const field2 = ownAnswer ? (0, import_react6.createElement)("textarea", { ++ ref: ownRef, ++ rows: 1, ++ value: own, ++ autoComplete: "off", ++ spellCheck: false, ++ className: "bt-q-input", ++ "aria-label": t("Your own answer"), ++ placeholder: t("Type your own answer"), ++ "aria-keyshortcuts": ownLetter?.toLowerCase(), + onChange: (event) => setOwn(event.target.value), + onKeyDown: (event) => { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; @@ -5455,101 +5474,223 @@ diff --git a/client.js b/client.js + ); + })) + ); -+} -+function QuestionCard({ matched }) { -+ const pending = matched; -+ const questions = pending.questions ?? []; -+ const [index, setIndex] = (0, import_react6.useState)(0); -+ const [answers, setAnswers] = (0, import_react6.useState)([]); -+ const question = questions[index]; -+ if (question === void 0) return null; -+ const onAnswer = (answer) => { -+ const next = [...answers, { id: question.id, ...answer }]; -+ if (index + 1 < questions.length) { -+ setAnswers(next); -+ setIndex(index + 1); -+ return; -+ } -+ pending.answer({ answers: next }); -+ }; -+ return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+} -+function PendingQuestion({ sessionId, question, actions, inline }) { -+ const [answered, setAnswered] = (0, import_react6.useState)(null); -+ if (answered === question.id) return null; -+ const onAnswer = (answer) => { -+ setAnswered(question.id); -+ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -+ }; -+ const onDismiss = () => { -+ setAnswered(question.id); -+ void actions.dismissQuestion(sessionId); -+ }; -+ return (0, import_react6.createElement)( -+ "div", -+ { className: inline ? "bt-q-inline" : "bt-q-dock" }, -+ (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -+ ); -+} -+function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const question = useRoster((value) => value.questions?.[sessionId]); -+ 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; -+ return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+} -+function AnsweredCell({ node }) { -+ const data = node.data ?? {}; -+ const questions = Array.isArray(data.questions) ? data.questions : []; -+ if (questions.length === 0) { -+ return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -+ } -+ const answers = Array.isArray(data.answers) ? data.answers : []; -+ return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { -+ key: question.id, -+ question, -+ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -+ }))); -+} -+ -+// src/client/secret-card.js -+var import_react7 = require("react"); -+var MIN_VALUE = 4; -+var NONE = {}; -+var KeyIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -+ (0, import_react7.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) -+); -+var LockIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -+ (0, import_react7.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) -+); -+var WarnIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react7.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -+ (0, import_react7.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) -+); -+var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); -+var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -+var scopeLabel = (scope, roster) => { -+ if (scope === "all") return t("All my Bots"); -+ const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -+ return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -+}; -+var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -+function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -+ return (0, import_react7.createElement)("input", { -+ className: "bt-secret-input", -+ type: MASK_BY_CSS ? "text" : "password", -+ "data-masked": MASK_BY_CSS ? "" : void 0, -+ value, -+ autoFocus, -+ autoComplete: MASK_BY_CSS ? "off" : "new-password", + } +-@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} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +-[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} +-[data-composer-card]>[class*="_row"]{display:contents!important} +-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} +-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} +-[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} +-[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} +-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-plus:active{scale:.9} +-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} +-.bt-plus[aria-expanded=true] svg{rotate:45deg} +-.bt-plus-menu{min-width:180px} +-[data-composer-card] [class*="_tools"]{gap:0!important} +-[data-composer-card] [class*="_modes"]:empty{display:none!important} +-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} +-[data-composer-card] [class*="_trailing"]{gap:8px} +-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} +-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} +-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} +-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} +-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} +-[data-composer-card] button[class*="_primary"]:disabled{display:none!important} +-[data-composer-dock]{display:none!important} +-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} +-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} +-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} +-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-voice:hover{opacity:.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} +-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} +-.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} +-div:has(> nav [class*="_marks"]){display:none!important} +-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) +- takes its surface and its text from the one --bt-user pair: a skin that repaints +- the host's bubble surface alone would otherwise leave the text unreadable. */ +-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} +-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} +-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ +-[class*="_userRow"] [class*="_actions"]{display:none!important} +-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} +-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} +-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} +-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} +-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} +-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} +-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} +-.bt-q-inline{display:flex;margin:4px 0 6px} +-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} +-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} +-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} +-.bt-q-dock{width:100%;display:flex;justify-content:center} +-.bt-q-dock .bt-q{margin:0 auto 8px} +-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} +-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} +-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-[class*="_toBottomSlot"]>button:active{scale:.9} +-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} +-[class*="_toBottomSlot"]>button svg{display:none} +-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} +-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} +-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} +-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +-@keyframes bt-news-in{from{translate:0 8px;scale:.9}} +-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} +-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} +-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} +-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} +-.bt-news-ico svg{width:16px;height:16px} +-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} +-.bt-news-x svg{width:12px;height:12px} +-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} +-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} +-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} +-.bt-open-new{display:flex;flex-direction:column} +-.bt-open-new .bt-new-sep{margin:18px 0 0} +-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} +-.bt-earlier{display:flex;flex-direction:column;gap:6px} +-.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} +-.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} +-.bt-uimages{display:block;font-size:12px;opacity:.7} +-.bt-earlier .bt-event{margin:4px 0} +-.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)} +-`; +-function installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; ++function QuestionCard({ matched }) { ++ const pending = matched; ++ const questions = pending.questions ?? []; ++ const [index, setIndex] = (0, import_react6.useState)(0); ++ const [answers, setAnswers] = (0, import_react6.useState)([]); ++ const question = questions[index]; ++ if (question === void 0) return null; ++ const onAnswer = (answer) => { ++ const next = [...answers, { id: question.id, ...answer }]; ++ if (index + 1 < questions.length) { ++ setAnswers(next); ++ setIndex(index + 1); ++ return; ++ } ++ pending.answer({ answers: next }); ++ }; ++ return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + } +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); ++function PendingQuestion({ sessionId, question, actions, inline }) { ++ const [answered, setAnswered] = (0, import_react6.useState)(null); ++ if (answered === question.id) return null; ++ const onAnswer = (answer) => { ++ setAnswered(question.id); ++ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); ++ }; ++ const onDismiss = () => { ++ setAnswered(question.id); ++ void actions.dismissQuestion(sessionId); ++ }; ++ return (0, import_react6.createElement)( ++ "div", ++ { className: inline ? "bt-q-inline" : "bt-q-dock" }, ++ (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ++ ); ++} ++function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { ++ const question = useRoster((value) => value.questions?.[sessionId]); ++ 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; ++ return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++} ++function AnsweredCell({ node }) { ++ const data = node.data ?? {}; ++ const questions = Array.isArray(data.questions) ? data.questions : []; ++ if (questions.length === 0) { ++ return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; ++ } ++ const answers = Array.isArray(data.answers) ? data.answers : []; ++ return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { ++ key: question.id, ++ question, ++ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } ++ }))); ++} ++ ++// src/client/secret-card.js ++var import_react7 = require("react"); ++var MIN_VALUE = 4; ++var NONE = {}; ++var KeyIcon = () => (0, import_react7.createElement)( ++ "svg", ++ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react7.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), ++ (0, import_react7.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) ++); ++var LockIcon = () => (0, import_react7.createElement)( ++ "svg", ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react7.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), ++ (0, import_react7.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) ++); ++var WarnIcon = () => (0, import_react7.createElement)( ++ "svg", ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react7.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), ++ (0, import_react7.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) ++); ++var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); ++var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); ++var scopeLabel = (scope, roster) => { ++ if (scope === "all") return t("All my Bots"); ++ const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); ++ return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++}; ++var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; ++function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { ++ return (0, import_react7.createElement)("input", { ++ className: "bt-secret-input", ++ type: MASK_BY_CSS ? "text" : "password", ++ "data-masked": MASK_BY_CSS ? "" : void 0, ++ value, ++ autoFocus, ++ autoComplete: MASK_BY_CSS ? "off" : "new-password", + autoCorrect: "off", + autoCapitalize: "off", + spellCheck: false, @@ -5564,7 +5705,7 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + onEnter?.(); -+ } + } + }); +} +function ScopeSwitch({ value, onChange, bot }) { @@ -5614,7 +5755,10 @@ diff --git a/client.js b/client.js + const long = value.trim().length >= MIN_VALUE; + const save = () => { + if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -+ }; + }; +- try { +- visit(sheet.cssRules); +- } catch { + const allow = () => { + if (!busy) void run(actions.secretAllow(request.id)); + }; @@ -5686,9 +5830,20 @@ diff --git a/client.js b/client.js + if (request) return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, (0, import_react7.createElement)(RequestView, { bot, request, roster, actions })); + if (done && /already saved/.test(contentText(content))) { + return (0, import_react7.createElement)("div", { className: "bt-event" }, (0, import_react7.createElement)("span", { className: "bt-secret-event" }, (0, import_react7.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -+ } + } +- return names; + return null; -+} + } +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); +- } +- return compiled.get(fragment); +- }); +function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const shown = useCards?.((state) => state[sessionId]) ?? NONE; @@ -5699,7 +5854,18 @@ diff --git a/client.js b/client.js + const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); + if (waiting.length === 0) return null; + return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+} + } +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); +var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; +function ScopeEditor({ secret, roster, onSave }) { + const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); @@ -5745,13 +5911,33 @@ diff --git a/client.js b/client.js + if (ok) { + setValue(""); + setReplaced(true); -+ } -+ }); -+ }; -+ const edit = (next) => { + } +- for (const name of list) names.add(name); +- } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); ++ }); + }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); ++ const edit = (next) => { + setValue(next); + setReplaced(false); -+ }; + }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); + return (0, import_react7.createElement)( + "div", + { className: "bt-secret-edit-block" }, @@ -5821,7 +6007,7 @@ diff --git a/client.js b/client.js + onDone(); + } + }); -+ }; + }; + return (0, import_react7.createElement)( + "div", + { className: "bt-set-row bt-set-paste bt-secret-add" }, @@ -5860,7 +6046,7 @@ diff --git a/client.js b/client.js + ), + adding ? null : (0, import_react7.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) + ]; -+} + } +var SECRET_CSS = ` +.bt-secret-head{align-items:center;gap:10px} +.bt-secret-who{position:relative;flex:none;display:inline-flex} @@ -5923,88 +6109,42 @@ diff --git a/client.js b/client.js +.bt-secret-danger svg{width:13px;height:13px} +.bt-secret-add .bt-input{height:34px} +@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} - `; --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%)} --.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.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} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.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} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.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 -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[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} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} --/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -- without its copy and branch row; a tail with nothing in it takes no room. */ --[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -- the shell's new-session hero: no headline, no workspace or mode picker, and the -- composer stays docked at the bottom. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[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} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[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} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.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))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} -+ ++`; + +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); @@ -6017,8 +6157,45 @@ diff --git a/client.js b/client.js + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) -+ }; -+ }; + }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 + }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +-} +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +- ); + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; @@ -6059,10 +6236,17 @@ diff --git a/client.js b/client.js + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; -+} + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) + +// src/client/held-reply.js +var import_react8 = require("react"); @@ -6140,15 +6324,43 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); -+} + ); + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; + if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; + return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); @@ -6198,8 +6410,15 @@ diff --git a/client.js b/client.js + }, emoji))) : null + ); + }) -+ ); -+} + ); + } +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text) { @@ -6211,21 +6430,52 @@ diff --git a/client.js b/client.js + if (participant?.remote !== true || participant.leftSeq === void 0) continue; + const name = String(participant.ownerName || participant.displayName || participant.name || "").trim(); + if (name && (who === name || who.endsWith(` (${name})`))) return true; -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + return false; -+} -+function useDismiss(open, close, refs) { -+ (0, import_react9.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (refs.some((ref) => ref.current?.contains(event.target))) return; -+ close(); + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +- ); +- } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++function useDismiss(open, close, refs) { ++ (0, import_react9.useEffect)(() => { ++ if (!open) return void 0; ++ const onDown = (event) => { ++ if (refs.some((ref) => ref.current?.contains(event.target))) return; ++ close(); + }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); @@ -6289,122 +6539,24 @@ diff --git a/client.js b/client.js + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null -+ ); + ); } --@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} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[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} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[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} --[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} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.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} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.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} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.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} --.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} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.bt-earlier .bt-event{margin:4px 0} --.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)} --`; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react9.createElement)( @@ -6412,16 +6564,10 @@ diff --git a/client.js b/client.js + { className: "bt-think" }, + (0, import_react9.createElement)("summary", null, (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) -+ ); + ); } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -6431,18 +6577,36 @@ diff --git a/client.js b/client.js + if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); -+} + } +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +- ); +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; -+} + } +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +- ); +function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text) => /^Not sent:/.test(text.trim()))) return void 0; + return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); -+} + } +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6480,8 +6644,27 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles -+ ); -+} + ); + } +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); +-}; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -6491,33 +6674,202 @@ diff --git a/client.js b/client.js + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null -+ ); -+} -+function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { -+ const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-group-msg bt-person-msg", "data-person": name }, -+ (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), -+ (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); -+} -+function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -+ (0, import_react9.createElement)("span", null, prefix), -+ (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), -+ (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); -+ } -+ const parts = []; -+ parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); + ); + } +-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"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-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"]); +-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"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-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() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) ++function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { ++ const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-group-msg bt-person-msg", "data-person": name }, ++ (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), ++ (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) + ); + } +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-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-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-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"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- 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; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +- }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); +-} +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +- } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); +- } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); +- } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); +- } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( ++function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { ++ if (whole && bots.length === 1) { ++ return (0, import_react9.createElement)( + "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( ++ { className: "bt-event" }, ++ (0, import_react9.createElement)( ++ "button", ++ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, ++ (0, import_react9.createElement)("span", null, prefix), ++ (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), ++ (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) ++ ) ++ ); ++ } ++ const parts = []; ++ parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { + if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react9.createElement)( @@ -6557,7 +6909,12 @@ diff --git a/client.js b/client.js + const held = heldLineState(room2, { seq, botId: bot.id, text }); + if (held === "rejected") { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) @@ -6580,8 +6937,14 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg" }, + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+ ); -+} + ); + } +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6622,7 +6985,14 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react9.createElement)( @@ -6633,12 +7003,25 @@ diff --git a/client.js b/client.js + ), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react9.createElement)("span", { className: "bt-lead", title: person }, (0, import_react9.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+ } + ); + } +- return (0, import_react4.createElement)( + const mark = (0, import_react9.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react9.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react9.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react9.createElement)( @@ -6649,21 +7032,34 @@ diff --git a/client.js b/client.js + ), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+} -+function FirstContact({ source, roster, sessionId }) { -+ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; -+ const who = person || remoteSpeaker(source, roster).name; -+ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); -+ return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); -+} -+function GlobeMark() { -+ return (0, import_react9.createElement)( -+ "svg", -+ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), -+ (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ); + ); + } +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; ++function FirstContact({ source, roster, sessionId }) { ++ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; ++ const who = person || remoteSpeaker(source, roster).name; ++ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); ++ return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); ++} ++function GlobeMark() { ++ return (0, import_react9.createElement)( ++ "svg", ++ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), ++ (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ ); +} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); @@ -6684,10 +7080,9 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); } +- pending.answer({ answers: next }); }; -- try { -- visit(sheet.cssRules); -- } catch { +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -6698,7 +7093,17 @@ diff --git a/client.js b/client.js + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); -+} + } +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( + "div", @@ -6727,8 +7132,7 @@ diff --git a/client.js b/client.js +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; - } -- return names; ++ } + return null; +}; +function EarlierParts({ sessionId, roster, actions }) { @@ -6751,7 +7155,8 @@ diff --git a/client.js b/client.js + busy.current = false; + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); -+ }; + }; +- return (0, import_react4.createElement)( + (0, import_react9.useEffect)(() => { + alive.current = true; + load(void 0); @@ -6781,7 +7186,9 @@ diff --git a/client.js b/client.js + else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + } + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react9.createElement)( + "div", @@ -6817,17 +7224,14 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) -+ ); + ); } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- 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; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6842,19 +7246,7 @@ diff --git a/client.js b/client.js + const order = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; - }); --} --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); ++ }); + const peers = useTurnClock((state) => { + if (!relayed) return ""; + const turns = state[sessionId] ?? {}; @@ -6886,8 +7278,7 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); - } -- for (const name of list) names.add(name); ++ } + if (source.role === "report") { + return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + } @@ -6912,28 +7303,7 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); -- }; ++ } + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); @@ -6941,44 +7311,7 @@ diff --git a/client.js b/client.js + if (source.kind === "user-question-reply") return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } - } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); ++} +function NewDivider() { + return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); +} @@ -6999,45 +7332,15 @@ diff --git a/client.js b/client.js + 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; + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); ++} +function Nothing() { + return null; } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -- ); +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -7086,74 +7389,185 @@ diff --git a/client.js b/client.js + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); -+ } + } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -+function clockLabel(time, now = Date.now()) { -+ const date = new Date(time); -+ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -+ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -+ if (days === 0) return t("Today {time}", { time: clock4 }); -+ if (days === 1) return t("Yesterday {time}", { time: clock4 }); -+ const sameYear = date.getFullYear() === new Date(now).getFullYear(); -+ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; - } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); -+function nextTurnStart(turns, turn) { -+ let next; -+ for (const key in turns) { -+ const candidate = Number(key); -+ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -+ } -+ return next === void 0 ? void 0 : turns[next].first; - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; -- } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; - } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +-}; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +- }); ++var SEPARATOR_GAP_MS = 30 * 60 * 1e3; ++function clockLabel(time, now = Date.now()) { ++ const date = new Date(time); ++ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); ++ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); ++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); ++ if (days === 0) return t("Today {time}", { time: clock4 }); ++ if (days === 1) return t("Yesterday {time}", { time: clock4 }); ++ const sameYear = date.getFullYear() === new Date(now).getFullYear(); ++ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +- ); ++function nextTurnStart(turns, turn) { ++ let next; ++ for (const key in turns) { ++ const candidate = Number(key); ++ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++ } ++ return next === void 0 ? void 0 : turns[next].first; + } +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- ); ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; + } +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) +- ); +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; @@ -7162,7 +7576,25 @@ diff --git a/client.js b/client.js + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; -+} + } +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -7188,16 +7620,18 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); -+} + } +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], + working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] @@ -7255,28 +7689,57 @@ diff --git a/client.js b/client.js + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +- return null; } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- 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; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); +} +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; -+} + } +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, +- type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) +- ); +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react9.useEffect)(() => { @@ -7284,7 +7747,17 @@ diff --git a/client.js b/client.js + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); -+} + } +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react9.useState)({ replies: [] }); + (0, import_react9.useEffect)(() => { @@ -7300,7 +7773,14 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); -+ } + } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; + void tick(); @@ -7316,24 +7796,20 @@ diff --git a/client.js b/client.js + } catch { + replies = []; + } - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); ++ } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) +- ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7398,9 +7874,7 @@ diff --git a/client.js b/client.js + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react9.createElement)(RemoteMessage, { @@ -7415,14 +7889,51 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }) ++ }) ); } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) +- ) +- ), +- open ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) +- ) +- ) : null +- ); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { @@ -7438,8 +7949,17 @@ diff --git a/client.js b/client.js + visit(root); + return calls; } --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7448,15 +7968,7 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); - } --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -- ); ++} +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7516,7 +8028,23 @@ diff --git a/client.js b/client.js + parsed = JSON.parse(contentText(call4.content)); + } catch { + parsed = null; -+ } + } +- }); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) +- ) +- ); + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; @@ -7533,9 +8061,7 @@ diff --git a/client.js b/client.js + default: + return null; + } - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); ++} +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; @@ -7552,13 +8078,29 @@ diff --git a/client.js b/client.js + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; @@ -7567,13 +8109,75 @@ diff --git a/client.js b/client.js + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); -+ +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[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} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +-`; + +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { @@ -7581,13 +8185,25 @@ diff --git a/client.js b/client.js + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) -- ); -+ +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); + +-// src/client/cells.js +-var import_react6 = require("react"); +-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") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); +- }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +// src/client/activity-row.js +var import_react10 = require("react"); +var import_react_dom2 = require("react-dom"); @@ -7598,9 +8214,7 @@ diff --git a/client.js b/client.js + if (minutes < 60) return `${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++} +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -7614,15 +8228,7 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; - } --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); ++} +function findHost(where) { + if (where !== TAIL_HOST) return document.querySelector(where); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -7636,15 +8242,7 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; - }; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -- ); ++}; +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react10.useRef)(-1); + const wasSending = (0, import_react10.useRef)(false); @@ -7672,99 +8270,38 @@ diff --git a/client.js b/client.js + }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + check(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, [remote, sending]); -+ return replied; - } --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"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --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"]); --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"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --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() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) -- ); -+function useRunningHost(active, where = RUNNING_HOST) { -+ const [host, setHost] = (0, import_react10.useState)(null); -+ (0, import_react10.useEffect)(() => { -+ if (!active) { -+ setHost(null); -+ return void 0; -+ } -+ let frame = 0; -+ const find = () => { -+ frame = 0; -+ const node = findHost(where); -+ setHost((prev) => prev === node ? prev : node); -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(find); -+ }); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ find(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, [active, where]); -+ return host; - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --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-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --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"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- 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; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, [remote, sending]); ++ return replied; ++} ++function useRunningHost(active, where = RUNNING_HOST) { ++ const [host, setHost] = (0, import_react10.useState)(null); ++ (0, import_react10.useEffect)(() => { ++ if (!active) { ++ setHost(null); ++ return void 0; ++ } ++ let frame = 0; ++ const find = () => { ++ frame = 0; ++ const node = findHost(where); ++ setHost((prev) => prev === node ? prev : node); ++ }; ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(find); ++ }); ++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); ++ find(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, [active, where]); ++ return host; ++} +function useRowEntrances(replacing) { + const check = (0, import_react10.useRef)(replacing); + check.current = replacing; @@ -7800,11 +8337,8 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); - } - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ } ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -7814,20 +8348,7 @@ diff --git a/client.js b/client.js + observer.disconnect(); + }; + }, []); - } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; ++} +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -7866,30 +8387,7 @@ diff --git a/client.js b/client.js + if (wait <= 0) { + make(); + return void 0; - } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++ } + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -7902,22 +8400,7 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( ++ } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -7961,143 +8444,67 @@ diff --git a/client.js b/client.js + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( -+ "div", + "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) +- ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null +- ); +- }) +- ); + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react10.createElement)( + "span", + { className: "bt-typing-text" }, + (0, import_react10.createElement)( - "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot -- ); --} --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) ++ "span", + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null - ) -- ); -- } -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) -- ); --} --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; -- } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ ) + ) + ), host); } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -- }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -8107,13 +8514,7 @@ diff --git a/client.js b/client.js + if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); - } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- 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; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++} + +// src/client/overlay-hooks.js +var import_react11 = require("react"); @@ -8127,24 +8528,69 @@ diff --git a/client.js b/client.js + (0, import_react11.useEffect)(() => { + if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); + escapeLayers.push(ref); -+ return () => { + return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); + escapeLayers.splice(escapeLayers.indexOf(ref), 1); + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; + }; +- }, [open]); + }, []); } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); +- return; +- } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); +- }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); +- }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -8152,36 +8598,8 @@ diff --git a/client.js b/client.js + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; - } - --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++} ++ +// src/client/member-status.js +var import_react12 = require("react"); +var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -8189,31 +8607,7 @@ diff --git a/client.js b/client.js +var stepIn = (work, botId, roomId, tool) => { + const own = work.bots?.[botId]; + return own?.roomId === roomId && own.tool === tool ? own.step : void 0; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); -- } ++}; +var workIn = (room2, work, id) => { + const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); + if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; @@ -8226,22 +8620,8 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; - }); - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) ++ }); ++} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); @@ -8274,18 +8654,12 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ); } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; @@ -8298,53 +8672,15 @@ diff --git a/client.js b/client.js + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -8358,28 +8694,45 @@ diff --git a/client.js b/client.js + waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null - ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) ++ ), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); ++ ); } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +function MemberDock({ sessionId, useRoster, useWork, actions }) { -+ const roster = useRoster((value) => value); + const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- 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; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- 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; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null +- ); + const work = useWork((value) => value); + const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; @@ -8395,46 +8748,12 @@ diff --git a/client.js b/client.js + const { rows, rest } = splitBusy(busy); + const open = () => actions.showDetails(sessionId); + return (0, import_react12.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); --} --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); --} --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); ++ "div", + { ref, className: "bt-mw-dock-stack" }, + rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); - } -- return null; --} --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- 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; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++ } + return (0, import_react12.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react12.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -8445,39 +8764,7 @@ diff --git a/client.js b/client.js + now, + onOpen: () => actions.openSession(item.room.id) + }))); - } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); ++} +function useFlowColumn(ref, active, key) { + (0, import_react12.useLayoutEffect)(() => { + const node = ref.current; @@ -8495,22 +8782,32 @@ diff --git a/client.js b/client.js + return () => observer.disconnect(); + }, [active, key]); } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -- } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -8519,7 +8816,14 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [active]); + return now; -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], @@ -8530,91 +8834,37 @@ diff --git a/client.js b/client.js + const answer = (choice) => { + setBusy(true); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); - }; -- return (0, import_react5.createElement)( ++ }; + return (0, import_react12.createElement)( "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +- "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, + approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react12.createElement)( - "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -- ); --} --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -- ), -- open ? (0, import_react5.createElement)( ++ "div", + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), + ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) + ) : (0, import_react12.createElement)( - "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null ++ "div", + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) + ) ); } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react12.useState)(false); + if (entry.state === "ask") { @@ -8629,171 +8879,33 @@ diff --git a/client.js b/client.js + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; -- return (0, import_react5.createElement)( ++ }; + return (0, import_react12.createElement)( - "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + { className: "bt-mw-member" }, + (0, import_react12.createElement)( - "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ++ "div", + { className: "bt-mw-state bt-mw-work" }, + (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) ++ ) ); } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; --} --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[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} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; -- --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); --} --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --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") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- 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 }); - return (0, import_react6.createElement)( - "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) - ); +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; @@ -8801,22 +8913,8 @@ diff --git a/client.js b/client.js + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -8905,39 +9003,17 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); - }; -- }, [open]); ++ }; + }, []); + return tasks; } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; -- } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -8955,9 +9031,29 @@ diff --git a/client.js b/client.js + if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; + if (question.question !== text) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; -+ } + } +- return null; + return void 0; -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); @@ -9069,11 +9165,11 @@ diff --git a/client.js b/client.js + ...people(roster, bot.id) }); }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -9113,14 +9209,69 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) -+ }; + }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); + else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); + else if (task.status === "open" || task.status === "claimed" || task.status === "working") progress.push({ ...card, status: "task", since: task.createdAt }); + else if ((task.status === "accepted" || task.status === "cancelled") && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "accepted" ? "done" : "cancelled" }); -+ } + } +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; @@ -9195,31 +9346,7 @@ diff --git a/client.js b/client.js + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null ++ }; + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-result" }, @@ -9245,14 +9372,8 @@ diff --git a/client.js b/client.js + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) - ); - } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) ++ ); ++} +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react13.useState)(0); + const question = card.kind === "question"; @@ -9308,55 +9429,84 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); -+ } -+ }), + } + }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ); } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); --} --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +function InboxRow({ wide, useRoster, useWork, useUi, actions }) { const roster = useRoster((value) => value); - const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- 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; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; - const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- 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)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; - const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } - return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, +- 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), - whole: true - }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); +- } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -9376,45 +9526,31 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), + wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, + count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); ++ ); } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- 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; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); @@ -9432,16 +9568,11 @@ diff --git a/client.js b/client.js + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react13.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react13.createElement)( - "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react13.createElement)( + "span", @@ -9475,8 +9606,7 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react13.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -9485,15 +9615,10 @@ diff --git a/client.js b/client.js + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) + ) - ); ++ ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null -- ); +-function Nothing() { +- return null; +function useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9502,15 +9627,16 @@ diff --git a/client.js b/client.js + }, [ms]); + return now; } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- 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 }); -- return (0, import_react6.createElement)( +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9548,9 +9674,7 @@ diff --git a/client.js b/client.js + actions.openSession(card.chat); + }; + return (0, import_react13.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ "div", + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react13.createElement)( @@ -9590,16 +9714,15 @@ diff --git a/client.js b/client.js + )) + ) + ) - ); ++ ); } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); --} --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return null; +- return next === void 0 ? void 0 : turns[next].first; +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9662,27 +9785,7 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); ++}; +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; +} @@ -9718,7 +9821,15 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); -+} + } +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; + +// src/shared/board-card.js +var CARD_SOURCES = ["github", "task"]; @@ -9751,43 +9862,32 @@ diff --git a/client.js b/client.js + linked: row.linked ?? null, + sha: row.sha ?? null, + url: row.url ?? null - }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -- return () => { -- alive.current = false; -- }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); ++ }; + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; - (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -- } +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; - return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -+} +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +- ); +function taskCard(task) { + const key = taskCardKey(task?.id); + if (key === null) return null; @@ -9807,10 +9907,15 @@ diff --git a/client.js b/client.js + sha: null, + url: null + }; -+} + } +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +function sameCard(a, b) { + return Boolean(a?.key) && a.key === b?.key; -+} + } +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] + +// src/client/board-card.js +var import_react14 = require("react"); @@ -9825,7 +9930,59 @@ diff --git a/client.js b/client.js + owner: "bt-del-owner", + linked: "bt-del-linked", + sha: "bt-del-sha" -+}; + }; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +- } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +var TASK_SKIN = { + card: "bt-board-card", + top: "bt-board-card-top", @@ -9856,58 +10013,29 @@ diff --git a/client.js b/client.js + onDragStart = null, + onDragEnd = null +}) { -+ const labels = Array.isArray(card?.labels) ? card.labels : []; -+ return (0, import_react14.createElement)( -+ "article", -+ { -+ ...attrs, -+ className: skin.card, -+ "data-source": card?.source, -+ "data-key": card?.key ?? void 0, -+ draggable: draggable || void 0, -+ onDragStart: draggable ? (event) => { -+ event.dataTransfer?.setData("text/plain", dragValue); -+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; -+ onDragStart?.(); -+ } : void 0, -+ onDragEnd: draggable ? () => onDragEnd?.() : void 0 -+ }, -+ (0, import_react14.createElement)( -+ "span", -+ { className: skin.top }, -+ (0, import_react14.createElement)("span", { className: skin.id }, idText), -+ topExtra, -+ badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null - ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ const labels = Array.isArray(card?.labels) ? card.labels : []; ++ return (0, import_react14.createElement)( ++ "article", ++ { ++ ...attrs, ++ className: skin.card, ++ "data-source": card?.source, ++ "data-key": card?.key ?? void 0, ++ draggable: draggable || void 0, ++ onDragStart: draggable ? (event) => { ++ event.dataTransfer?.setData("text/plain", dragValue); ++ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; ++ onDragStart?.(); ++ } : void 0, ++ onDragEnd: draggable ? () => onDragEnd?.() : void 0 ++ }, ++ (0, import_react14.createElement)( ++ "span", ++ { className: skin.top }, ++ (0, import_react14.createElement)("span", { className: skin.id }, idText), ++ topExtra, ++ badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null ++ ), + (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -9918,33 +10046,8 @@ diff --git a/client.js b/client.js + card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, + footer, + actions - ); - } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++ ); ++} + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -10019,37 +10122,7 @@ diff --git a/client.js b/client.js + title: pointTitle(day, key), + value + }; - }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- 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)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); ++ }); + const segments = []; + let run = null; + for (const dot of dots) { @@ -10057,21 +10130,9 @@ diff --git a/client.js b/client.js + if (run) { + segments.push(run); + run = null; - } -- return (0, import_react6.createElement)(EventLine, { -- 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), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ } + continue; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); ++ } + if (!run) run = { dots: [] }; + run.dots.push(dot); } @@ -10089,8 +10150,9 @@ diff --git a/client.js b/client.js + })) + }; } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10125,21 +10187,13 @@ diff --git a/client.js b/client.js + }, (0, import_react15.createElement)("title", null, dot.title))) + ); } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- 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; +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); @@ -10165,11 +10219,7 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") + ]; + return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); - }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; --} --function Nothing() { -- return null; ++ }); + return (0, import_react15.createElement)( + "div", + { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, @@ -10179,16 +10229,37 @@ diff --git a/client.js b/client.js + days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null + ); } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +- if (done) return void 0; +- let stop = false; +- let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +- }; +- void tick(); +- return () => { +- stop = true; +- clearTimeout(timer); +- }; +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; +- } +var DELIVERY_CHART_CSS = ` +.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} @@ -10210,14 +10281,36 @@ diff --git a/client.js b/client.js + .bt-del-dm-row{flex-wrap:wrap;gap:4px 8px;min-height:44px;align-content:center} + .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} + .bt-del-dm-value{min-width:0} - } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++} +`; + ++// src/shared/issue-ref.js ++var ISSUE_REF = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi; ++function issueRefs(text) { ++ const keys = []; ++ for (const match of String(text ?? "").matchAll(new RegExp(ISSUE_REF.source, "gi"))) { ++ const key = `#${match[1] ?? match[2]}`; ++ if (!keys.includes(key)) keys.push(key); + } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( ++ return keys; ++} ++function namesIssue(text, issue) { ++ const number = String(issue ?? "").replace(/^#/, ""); ++ if (!/^\d+$/.test(number)) return false; ++ return issueRefs(text).includes(`#${number}`); ++} ++function cardNamesIssue(task, wanted) { ++ const own = `${task?.title ?? ""} ${task?.target?.href ?? ""}`; ++ if (namesIssue(own, wanted)) return true; ++ return issueRefs(own).length === 0 && namesIssue(task?.note ?? "", wanted); ++} ++var issueRefPattern = () => new RegExp(ISSUE_REF.source, "gi"); ++ +// src/client/delivery-view.js +var import_react16 = require("react"); +var POLL_MS3 = 6e4; @@ -10229,42 +10322,9 @@ diff --git a/client.js b/client.js + const at = Number(payload?.retryAt); + if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { + return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); - } -- return next === void 0 ? void 0 : turns[next].first; --} --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++ } + return POLL_MS3; - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null ++} +var DELIVERY_COLUMNS = [ + { id: "not-started", label: "Not started" }, + { id: "in-progress", label: "In progress" }, @@ -10321,7 +10381,39 @@ diff --git a/client.js b/client.js + "done": t("Done") + }; + return (0, import_react16.createElement)( -+ "div", + "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( + "section", @@ -10347,14 +10439,8 @@ diff --git a/client.js b/client.js + onDrag + })) + )) - ); - } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); --} --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] ++ ); ++} +function duration(ms) { + if (!Number.isFinite(ms) || ms < 0) return "—"; + const hours = ms / 36e5; @@ -10404,36 +10490,7 @@ diff --git a/client.js b/client.js + const at = Date.parse(iso ?? ""); + if (!Number.isFinite(at)) return "—"; + return new Date(at).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit" }); - }; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; ++}; +var timeOf = (iso) => { + const at = Date.parse(iso ?? ""); + if (!Number.isFinite(at)) return "—"; @@ -10502,7 +10559,18 @@ diff --git a/client.js b/client.js + state = "failed"; + last = null; + onView({ state: "failed", error: { message: "The host did not answer", kind: "network", endpoint: null, status: null, at: null } }); -+ } + } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); + schedule(); + } catch (error) { + if (stopped) return; @@ -10510,12 +10578,7 @@ diff --git a/client.js b/client.js + last = null; + onView({ state: "failed", error: { message: String(error?.message ?? error), kind: "network", endpoint: null, status: null, at: null } }); + schedule(); - } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; ++ } + }; + return { + start: () => { @@ -10531,30 +10594,16 @@ diff --git a/client.js b/client.js + if (timer !== null) { + clearTimer(timer); + timer = null; -+ } + } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + void run(true); + }, + stop: () => { + stopped = true; + if (timer !== null) clearTimer(timer); + timer = null; - } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; ++ } + }; +} +function repoSourceText(source) { @@ -10574,7 +10623,20 @@ diff --git a/client.js b/client.js + onView: (next) => { + current = next; + setView(next); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( +- "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); +- }) +- ); + }); + setRetry(() => () => poller.retry(current)); + poller.start(); @@ -10591,13 +10653,21 @@ diff --git a/client.js b/client.js + const status = row.status === null || row.status === void 0 ? "—" : String(row.status); + const where = report.repo === null && row.repo ? `${row.repo} ` : ""; + lines.push(`${where}${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); - } ++ } + if (report.error) lines.push(String(report.error)); + return lines.join("\n"); } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +-function callTree(root) { +- const calls = []; +- const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); +function Diagnose({ onProbe }) { + const [report, setReport] = (0, import_react16.useState)(null); + const [busy, setBusy] = (0, import_react16.useState)(false); @@ -10607,7 +10677,9 @@ diff --git a/client.js b/client.js + Promise.resolve(onProbe?.()).then((next) => { + setReport(next ?? { rows: [], error: "no answer" }); + }).catch((error) => setReport({ rows: [], error: String(error?.message ?? error) })).finally(() => setBusy(false)); -+ }; + }; +- visit(root); +- return calls; + return (0, import_react16.createElement)( + "span", + { className: "bt-del-diag" }, @@ -10630,13 +10702,14 @@ diff --git a/client.js b/client.js + ) : null + ); } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); +-function useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); +function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { + const payload = view ?? snapshot; + if (!payload) return null; @@ -10693,21 +10766,26 @@ diff --git a/client.js b/client.js + ) + ); } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +- const self = roster.byId[sessionId]; +- switch (tool) { +- case "message_bot": { +- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "stop_bot": { +- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "create_bot": { +- const created = findBot(arg("name")); +- if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); +var BOARD_FILTERS = ["all", "review", "done-today"]; +function BoardFilters({ filter, onFilter }) { + return (0, import_react16.createElement)( @@ -10725,41 +10803,197 @@ diff --git a/client.js b/client.js +} +var stageOf = (card, column) => card?.stage ?? column; +var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; ++var TERMINAL_TASK = /* @__PURE__ */ new Set(["accepted", "cancelled", "failed"]); ++var newestTask = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0); ++var cardRepoOf = (card, fallback) => { ++ const own = typeof card?.repo === "string" ? card.repo.trim() : ""; ++ if (own !== "") return own; ++ return typeof fallback === "string" ? fallback.trim() : ""; ++}; ++var recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}`; ++function namedRepos(task) { ++ if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`]; ++ const text = `${task?.title ?? ""} ++${task?.note ?? ""}`; ++ const repos = []; ++ for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`); ++ for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`); ++ return repos; ++} ++var repoOk = (task, repo) => { ++ const named = namedRepos(task); ++ if (named.length === 0 || repo === "") return true; ++ return named.includes(repo); ++}; ++function hangRecords(columns, tasks, repo) { ++ const issues = []; ++ for (const cards of Object.values(columns ?? {})) { ++ for (const card of cards ?? []) { ++ if (card?.kind !== "issue" || card.number == null) continue; ++ issues.push({ card, repo: cardRepoOf(card, repo) }); + } +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; ++ } ++ const nested = /* @__PURE__ */ new Map(); ++ const taken = /* @__PURE__ */ new Set(); ++ const open = (tasks ?? []).filter((task) => task && task.kind !== "group" && !TERMINAL_TASK.has(task.status) && task.id != null); ++ for (const task of open) { ++ let best = null; ++ let bestScore = -1; ++ const hits = []; ++ for (const issue of issues) { ++ if (!repoOk(task, issue.repo)) continue; ++ const namesIssue2 = cardNamesIssue(task, issue.card.number); ++ const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked); ++ if (!namesIssue2 && !namesPull) continue; ++ hits.push(issue); + } +- case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); +- case "create_group": +- case "update_group": +- case "delete_group": +- case "post_to_group": { +- const verbs = { +- create_group: ["Created group", "Couldn't create the group"], +- update_group: ["Updated group", "Couldn't update the group"], +- delete_group: ["Deleted group", "Couldn't delete the group"], +- post_to_group: ["Posted in", "Couldn't post in the group"] +- }[tool]; +- if (!done) return null; +- const result = contentText(call2.content); +- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); +- const name = named[2]; +- const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); ++ const bare2 = namedRepos(task).length === 0; ++ const pool = bare2 && hits.length > 1 ? [] : hits; ++ for (const issue of pool) { ++ const target = Number(task.target?.number); ++ const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0; ++ if (score > bestScore) { ++ best = issue; ++ bestScore = score; ++ } + } +- case "ask_user": { +- const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); ++ if (!best) continue; ++ const key = recordKey(best.repo, best.card.number); ++ const list = nested.get(key) ?? []; ++ list.push(task); ++ nested.set(key, list); ++ } ++ const chosen = /* @__PURE__ */ new Map(); ++ for (const [key, list] of nested) { ++ const [first, ...rest] = [...list].sort(newestTask); ++ chosen.set(key, first); ++ taken.add(first.id); ++ for (const extra of rest) taken.add(extra.id); ++ } ++ return { nested: chosen, loose: (tasks ?? []).filter((task) => task && !taken.has(task.id)) }; ++} +function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = view?.columns ?? {}; + const drawn = placed === null || placed === void 0 ? null : new Set(placed); + const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); -+ const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; -+ for (const task of filter === "all" ? tasks ?? [] : []) { ++ const shown = {}; ++ for (const column of stagesOf(view)) shown[column.id] = deliveryCards(column.id); ++ const hung = hangRecords(shown, filter === "all" ? tasks ?? [] : [], repo); ++ const looseByColumn = {}; ++ for (const task of hung.loose) { + const col = taskColumnOf(task); -+ if (tasksByColumn[col]) tasksByColumn[col].push(task); ++ (looseByColumn[col] ??= []).push(task); + } -+ const allCards = (id) => { -+ const delivery = deliveryCards(id); -+ const taskList = tasksByColumn[id] ?? []; -+ return [...taskList, ...delivery]; ++ const taskNode = (task) => (0, import_react16.createElement)(BoardCard2, { ++ task, ++ roster, ++ movable: canMoveTask(roster, task), ++ onMove, ++ onDrag ++ }); ++ const rowsOf = (id) => { ++ const cards = shown[id] ?? []; ++ const linked = /* @__PURE__ */ new Set(); ++ for (const card of cards) { ++ if (card?.kind !== "issue" || card.linked == null) continue; ++ linked.add(recordKey(cardRepoOf(card, repo), card.linked)); + } +- case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); +- case "ask_user_question": { +- if (!done) return null; +- let parsed = null; +- try { +- parsed = JSON.parse(contentText(call2.content)); +- } catch { +- parsed = null; ++ const rows = []; ++ for (const task of looseByColumn[id] ?? []) rows.push((0, import_react16.createElement)(BoardCard2, { ++ key: task.id, ++ task, ++ roster, ++ movable: canMoveTask(roster, task), ++ onMove, ++ onDrag ++ })); ++ for (const card of cards) { ++ const where = cardRepoOf(card, repo); ++ if (card?.kind === "pr" && linked.has(recordKey(where, card.number))) continue; ++ const key = `${where}-${card.kind}-${card.number}`; ++ if (card?.kind === "issue") { ++ const issueKey = recordKey(where, card.number); ++ const task = hung.nested.get(issueKey); ++ if (task) { ++ rows.push((0, import_react16.createElement)( ++ "div", ++ { key: `nest-${issueKey}`, className: "bt-board-nest", "data-issue": issueKey }, ++ (0, import_react16.createElement)(DeliveryCard, { card, repo: where || null }), ++ (0, import_react16.createElement)("div", { className: "bt-board-subtask", "data-parent": issueKey }, taskNode(task)) ++ )); ++ continue; ++ } + } +- const answer = parsed?.answers?.[0]; +- const question = argValue("questions")?.[0]; +- if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); ++ rows.push((0, import_react16.createElement)(DeliveryCard, { key, card, repo: where || null })); + } +- case "read_group_chat": { +- if (!done) return null; +- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++ return rows; + }; -+ const taskIds = new Set((tasks ?? []).map((task) => task?.id)); -+ const isTask = (item) => taskIds.has(item?.id); ++ const drawnCount = (rows) => rows.reduce((count, row) => count + (row?.props?.className === "bt-board-nest" ? 2 : 1), 0); + return (0, import_react16.createElement)( + "div", + { className: "bt-del-columns" }, -+ stagesOf(view).map((column) => (0, import_react16.createElement)( -+ "section", -+ { -+ key: column.id, -+ className: "bt-del-column", -+ "data-column": column.id, -+ ...dropProps(column.id, { dragging, onMove, onDrag }) -+ }, -+ (0, import_react16.createElement)( -+ "h3", -+ { className: "bt-board-column-head" }, -+ (0, import_react16.createElement)("span", null, t(column.label)), -+ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) -+ ), -+ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) -+ )) ++ stagesOf(view).map((column) => { ++ const rows = rowsOf(column.id); ++ return (0, import_react16.createElement)( ++ "section", ++ { ++ key: column.id, ++ className: "bt-del-column", ++ "data-column": column.id, ++ ...dropProps(column.id, { dragging, onMove, onDrag }) ++ }, ++ (0, import_react16.createElement)( ++ "h3", ++ { className: "bt-board-column-head" }, ++ (0, import_react16.createElement)("span", null, t(column.label)), ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(drawnCount(rows))) ++ ), ++ rows.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : rows ++ ); ++ }) + ); +} +function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { @@ -10809,84 +11043,16 @@ diff --git a/client.js b/client.js + for (const [column, cards] of Object.entries(entry.columns)) { + if (merged.columns[column]) { + merged.columns[column].push(...cards.map((card) => ({ ...card, repo: card.repo ?? entry.repo }))); - } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); -- return () => { -- stop = true; -- clearTimeout(timer); -- }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; ++ } } +- case "remember": +- case "forget": +- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; +- default: +- return null; } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( + return (0, import_react16.createElement)( - "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); -- } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -- } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( ++ "div", + { className: "bt-del-multi" }, + // Successful repos: one shared column set with all cards and tasks once + successful.length > 0 ? (0, import_react16.createElement)(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) : null, @@ -10903,7 +11069,22 @@ diff --git a/client.js b/client.js + filter + })) + ); -+} + } +-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +- const roster = useRoster((value) => value); +- const root = node.data.root; +- const turn = node.location?.turn?.turn ?? root.turn; +- const hidden = isHiddenTurn(roster, sessionId, turn); +- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); +- const calls = hidden ? [] : callTree(root); +- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); +- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); +- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; +- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); +- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); +- if (drawn.length === 0) return null; +- const env = { sessionId, roster, actions, useUi, useWork, findBot }; +- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); +function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { + const state = entry?.state ?? "fresh"; + const source = repoSourceText(entry?.repoSource); @@ -10968,12 +11149,7 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, + (0, import_react16.createElement)( - "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); -- }) ++ "div", + { className: "bt-board-error", role: "alert" }, + (0, import_react16.createElement)("p", null, String(error?.message ?? message ?? t("The delivery board is not ready yet."))), + error ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, hintFor(error, { repo })) : null, @@ -11030,7 +11206,7 @@ diff --git a/client.js b/client.js + idText: `#${card?.number ?? "?"}`, + badge: badgeLabel(card?.badge) + }); -+} + } +var DELIVERY_CSS = ` +.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} +.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} @@ -11049,150 +11225,75 @@ diff --git a/client.js b/client.js +.bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} +.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} +.bt-del-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} -+.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} -+.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} -+.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} -+.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} -+.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} -+.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} -+.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} -+.bt-del-card-top{display:flex;align-items:center;gap:6px} -+.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} -+.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -+.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} -+.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} -+.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} -+.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} -+.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} -+@media (max-width:800px){ -+ .bt-del-columns{overflow:auto} -+ .bt-del-column{min-width:220px} -+} -+@media (max-width:640px){ -+ .bt-del-columns{flex-direction:column} -+ .bt-del-column{min-width:0} -+} -+` + DELIVERY_CHART_CSS; -+ -+// src/client/kanban.js -+var import_react17 = require("react"); -+var POLL_MS4 = 15e3; -+function BoardIcon() { -+ return (0, import_react17.createElement)( -+ "svg", -+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) - ); - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); -- }; -- visit(root); -- return calls; -+function shortId(id) { -+ const hex = String(id ?? "").replace(/^task-/, ""); -+ return `#${hex.slice(-6)}`; - } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); -+var ARCHIVE_SHOWN = 20; -+function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { -+ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); -+ return [...tasks ?? [], ...newest.slice(0, limit)]; - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -- } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -- } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -- } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -- } -- case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); -- case "ask_user_question": { -- if (!done) return null; -- let parsed = null; -- try { -- parsed = JSON.parse(contentText(call2.content)); -- } catch { -- parsed = null; -- } -- const answer = parsed?.answers?.[0]; -- const question = argValue("questions")?.[0]; -- if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); -- } -- case "read_group_chat": { -- if (!done) return null; -- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} ++.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} ++.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} ++.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} ++.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} ++.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} ++.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} ++.bt-del-card-top{display:flex;align-items:center;gap:6px} ++.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} ++.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} ++.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} ++.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} ++.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} ++.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} ++.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} ++@media (max-width:800px){ ++ .bt-del-columns{overflow:auto} ++ .bt-del-column{min-width:220px} ++} ++@media (max-width:640px){ ++ .bt-del-columns{flex-direction:column} ++ .bt-del-column{min-width:0} ++} ++` + DELIVERY_CHART_CSS; + +-// src/client/activity-row.js +-var import_react7 = require("react"); +-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))]); +-function elapsedLabel(ms) { +- const minutes = Math.floor(ms / 6e4); +- if (minutes < 60) return `${Math.max(1, minutes)}m`; +- const hours = Math.floor(minutes / 60); +- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; ++// src/client/kanban.js ++var import_react17 = require("react"); ++var POLL_MS4 = 15e3; ++function BoardIcon() { ++ return (0, import_react17.createElement)( ++ "svg", ++ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ++ ); + } +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +- if (!active) { +- setHost(null); +- return void 0; ++function shortId(id) { ++ const hex = String(id ?? "").replace(/^task-/, ""); ++ return `#${hex.slice(-6)}`; ++} ++var ARCHIVE_SHOWN = 20; ++function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { ++ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); ++ return [...tasks ?? [], ...newest.slice(0, limit)]; ++} +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { @@ -11250,9 +11351,23 @@ diff --git a/client.js b/client.js + rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); + walk(bot.id, depth + 1); } -- case "remember": -- case "forget": -- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; +- let frame = 0; +- const find = () => { +- frame = 0; +- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); +- setHost((prev) => prev === node ? prev : node); +- }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(find); +- }); +- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); +- find(); +- return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); +- }; +- }, [active]); +- return host; + }; + walk("", 0); + const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); @@ -11286,45 +11401,46 @@ diff --git a/client.js b/client.js + return t("Cancelled"); + case "failed": + return t("Failed"); - default: -- return null; ++ default: + return t("Not started"); - } ++ } } --function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { -- const roster = useRoster((value) => value); -- const root = node.data.root; -- const turn = node.location?.turn?.turn ?? root.turn; -- const hidden = isHiddenTurn(roster, sessionId, turn); -- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); -- const calls = hidden ? [] : callTree(root); -- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); -- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); -- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); -- if (drawn.length === 0) return null; -- const env = { sessionId, roster, actions, useUi, useWork, findBot }; -- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); --} -- --// src/client/activity-row.js --var import_react7 = require("react"); --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))]); --function elapsedLabel(ms) { -- const minutes = Math.floor(ms / 6e4); -- if (minutes < 60) return `${Math.max(1, minutes)}m`; -- const hours = Math.floor(minutes / 60); -- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; --} --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); +-function useRowEntrances(replacing) { +- const check = (0, import_react7.useRef)(replacing); +- check.current = replacing; - (0, import_react7.useEffect)(() => { -- if (!active) { -- setHost(null); -- return void 0; +- const seen = /* @__PURE__ */ new WeakSet(); +- const started = Date.now(); +- let column = null; +- let frame = 0; +- const settle = (event) => { +- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; +- delete event.currentTarget.dataset.btEnter; +- event.currentTarget.removeEventListener("animationend", settle); +- }; +- const scan = () => { +- frame = 0; +- const next = document.querySelector("[data-chat-flow]"); +- if (next !== column) { +- observer.disconnect(); +- column = next; +- if (column) observer.observe(column, { childList: true, subtree: true }); +- } +- if (!column) return; +- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; +- let last = -1; +- items.forEach((item, index) => { +- if (seen.has(item)) last = index; +- }); +- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); +- for (const item of fresh) seen.add(item); +- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; +- for (const item of fresh) { +- if (items.indexOf(item) < last) continue; +- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); +- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; +- item.addEventListener("animationend", settle); +- } +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); + if (column === "done") return t("Complete task"); @@ -11346,12 +11462,7 @@ diff --git a/client.js b/client.js + if (!map.has(id)) { + map.set(id, []); + order.push(id); - } -- let frame = 0; -- const find = () => { -- frame = 0; -- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); -- setHost((prev) => prev === node ? prev : node); ++ } + map.get(id).push(task); + } + return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); @@ -11380,10 +11491,9 @@ diff --git a/client.js b/client.js + }).catch(() => void 0); }; - const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(find); +- if (!frame) frame = requestAnimationFrame(scan); - }); -- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -- find(); +- scan(); + load(); + const timer = setInterval(load, POLL_MS4); return () => { @@ -11392,8 +11502,7 @@ diff --git a/client.js b/client.js + stopped = true; + clearInterval(timer); }; -- }, [active]); -- return host; +- }, []); + }, [key]); + const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const replace = (task) => { @@ -11405,69 +11514,6 @@ diff --git a/client.js b/client.js + const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + return { own, tasks, archive: done, replace }; } --function useRowEntrances(replacing) { -- const check = (0, import_react7.useRef)(replacing); -- check.current = replacing; -- (0, import_react7.useEffect)(() => { -- const seen = /* @__PURE__ */ new WeakSet(); -- const started = Date.now(); -- let column = null; -- let frame = 0; -- const settle = (event) => { -- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -- delete event.currentTarget.dataset.btEnter; -- event.currentTarget.removeEventListener("animationend", settle); -- }; -- const scan = () => { -- frame = 0; -- const next = document.querySelector("[data-chat-flow]"); -- if (next !== column) { -- observer.disconnect(); -- column = next; -- if (column) observer.observe(column, { childList: true, subtree: true }); -- } -- if (!column) return; -- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -- let last = -1; -- items.forEach((item, index) => { -- if (seen.has(item)) last = index; -- }); -- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -- for (const item of fresh) seen.add(item); -- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -- for (const item of fresh) { -- if (items.indexOf(item) < last) continue; -- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -- item.addEventListener("animationend", settle); -- } -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); -- }); -- scan(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- }; -- }, []); -+function RailButton({ row, selected, meta, onSelect }) { -+ return (0, import_react17.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-board-rail-item", -+ "data-kind": row.kind, -+ "data-id": row.id, -+ "data-selected": selected || void 0, -+ "aria-current": selected ? "true" : void 0, -+ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, -+ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) -+ }, -+ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), -+ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null -+ ); - } -function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { - const roster = useRoster((value) => value); - const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -11542,6 +11588,23 @@ diff --git a/client.js b/client.js - if (!host || phase === null || !shown) return null; - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; - return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( ++function RailButton({ row, selected, meta, onSelect }) { ++ return (0, import_react17.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-board-rail-item", ++ "data-kind": row.kind, ++ "data-id": row.id, ++ "data-selected": selected || void 0, ++ "aria-current": selected ? "true" : void 0, ++ style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, ++ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) ++ }, ++ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), ++ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ++ ); ++} +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 5bc6c761a..025dc4949 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -13,7 +13,8 @@ import { createElement as h, useEffect, useState } from 'react' import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' -import { githubCard } from '../shared/board-card.js' +import { githubCard, githubCardKey } from '../shared/board-card.js' +import { cardNamesIssue } from '../shared/issue-ref.js' import { BoardCard, DELIVERY_SKIN } from './board-card.js' import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' @@ -94,7 +95,8 @@ const dropProps = (column, { dragging, onMove, onDrag }) => ({ }, }) -// Simplified task column renderer for degraded states (no GitHub snapshot). +// No issue cards on the board (no token, a failed read, a snapshot that never arrived): every +// record stays a top-level card, including one that names an issue the snapshot does not have. function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = { 'not-started': [], 'in-progress': [], 'done': [] } for (const task of tasks ?? []) { @@ -437,6 +439,85 @@ function BoardFilters({ filter, onFilter }) { const stageOf = (card, column) => card?.stage ?? column const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter +const TERMINAL_TASK = new Set(['accepted', 'cancelled', 'failed']) +const newestTask = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0) + +const cardRepoOf = (card, fallback) => { + const own = typeof card?.repo === 'string' ? card.repo.trim() : '' + if (own !== '') return own + return typeof fallback === 'string' ? fallback.trim() : '' +} + +const recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}` + +// The repository a record already names, read off the same text `cardNamesIssue` matches. +// A bare `#12` names no repository. `target` is the host's normalised pointer and wins over a +// mention in the title, which is how a dispatch and the board stay on one card. +function namedRepos(task) { + if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`] + const text = `${task?.title ?? ''}\n${task?.note ?? ''}` + const repos = [] + for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`) + for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`) + return repos +} + +const repoOk = (task, repo) => { + const named = namedRepos(task) + if (named.length === 0 || repo === '') return true + return named.includes(repo) +} + +// A record hangs under the issue `cardNamesIssue` would hand a dispatch (#1254, #1331). The +// issue's `linked` pull request is the closing-keyword link the snapshot already computed (#1189), +// so a record that names that pull request hangs under the issue too. One open record per issue; +// an older open record that names the same issue is not drawn again. +export function hangRecords(columns, tasks, repo) { + const issues = [] + for (const cards of Object.values(columns ?? {})) { + for (const card of cards ?? []) { + if (card?.kind !== 'issue' || card.number == null) continue + issues.push({ card, repo: cardRepoOf(card, repo) }) + } + } + const nested = new Map() + const taken = new Set() + const open = (tasks ?? []).filter(task => task && task.kind !== 'group' && !TERMINAL_TASK.has(task.status) && task.id != null) + for (const task of open) { + let best = null + let bestScore = -1 + const hits = [] + for (const issue of issues) { + if (!repoOk(task, issue.repo)) continue + const namesIssue = cardNamesIssue(task, issue.card.number) + const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked) + if (!namesIssue && !namesPull) continue + hits.push(issue) + } + // A bare `#12` on a board where two repositories both have that issue names neither. + const bare = namedRepos(task).length === 0 + const pool = bare && hits.length > 1 ? [] : hits + for (const issue of pool) { + const target = Number(task.target?.number) + const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0 + if (score > bestScore) { best = issue; bestScore = score } + } + if (!best) continue + const key = recordKey(best.repo, best.card.number) + const list = nested.get(key) ?? [] + list.push(task) + nested.set(key, list) + } + const chosen = new Map() + for (const [key, list] of nested) { + const [first, ...rest] = [...list].sort(newestTask) + chosen.set(key, first) + taken.add(first.id) + for (const extra of rest) taken.add(extra.id) + } + return { nested: chosen, loose: (tasks ?? []).filter(task => task && !taken.has(task.id)) } +} + // One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {} @@ -446,41 +527,68 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter const deliveryCards = (id) => (columns[id] ?? []) .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) .filter((card) => matchesFilter(card, id, filter)) - - // #1326: Task cards map into the same columns. Map task column to delivery column. - const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } - // A task card names no review or done-today stage, so a stage filter narrows it away rather than - // moving it to a lane it never named (#1326). - for (const task of filter === 'all' ? tasks ?? [] : []) { + + // A stage filter narrows task cards away: a record names no review or done-today stage (#1326). + // Nesting reads every column this view draws, so a record leaves its own column to hang under + // the issue wherever that card sits. A card a workflow section owns is not in `shown`. + const shown = {} + for (const column of stagesOf(view)) shown[column.id] = deliveryCards(column.id) + const hung = hangRecords(shown, filter === 'all' ? tasks ?? [] : [], repo) + const looseByColumn = {} + for (const task of hung.loose) { const col = taskColumnOf(task) - if (tasksByColumn[col]) tasksByColumn[col].push(task) + ;(looseByColumn[col] ??= []).push(task) } - - const allCards = (id) => { - const delivery = deliveryCards(id) - const taskList = tasksByColumn[id] ?? [] - return [...taskList, ...delivery] + const taskNode = (task) => h(TaskBoardCard, { + task, roster, movable: canMoveTask(roster, task), onMove, onDrag, + }) + const rowsOf = (id) => { + const cards = shown[id] ?? [] + // A pull request the issue already links is not a second card in that issue's column. + // A filter that hides the issue (review) still draws the pull request. + const linked = new Set() + for (const card of cards) { + if (card?.kind !== 'issue' || card.linked == null) continue + linked.add(recordKey(cardRepoOf(card, repo), card.linked)) + } + const rows = [] + for (const task of looseByColumn[id] ?? []) rows.push(h(TaskBoardCard, { + key: task.id, task, roster, movable: canMoveTask(roster, task), onMove, onDrag, + })) + for (const card of cards) { + const where = cardRepoOf(card, repo) + if (card?.kind === 'pr' && linked.has(recordKey(where, card.number))) continue + const key = `${where}-${card.kind}-${card.number}` + if (card?.kind === 'issue') { + const issueKey = recordKey(where, card.number) + const task = hung.nested.get(issueKey) + if (task) { + rows.push(h('div', { key: `nest-${issueKey}`, className: 'bt-board-nest', 'data-issue': issueKey }, + h(DeliveryCard, { card, repo: where || null }), + h('div', { className: 'bt-board-subtask', 'data-parent': issueKey }, taskNode(task)))) + continue + } + } + rows.push(h(DeliveryCard, { key, card, repo: where || null })) + } + return rows } - - // The two kinds share these columns, so which one a card is has to come from where it was read, - // never from guessing at its fields. - const taskIds = new Set((tasks ?? []).map(task => task?.id)) - const isTask = (item) => taskIds.has(item?.id) - + const drawnCount = (rows) => rows.reduce((count, row) => count + (row?.props?.className === 'bt-board-nest' ? 2 : 1), 0) + return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }), - }, + stagesOf(view).map(column => { + const rows = rowsOf(column.id) + return h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }), + }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), - allCards(column.id).length === 0 + h('span', { className: 'bt-board-count' }, String(drawnCount(rows)))), + rows.length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : allCards(column.id).map(item => - isTask(item) - ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) + : rows) + })) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index d7891f38e..224ba741a 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -18,7 +18,7 @@ import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, sameRole } from '../shared/task-board.js' import { currentParticipants, deriveParticipants } from './room-membership.js' -import { issueRefs, namesIssue } from './work-dispatch.js' +import { cardNamesIssue } from '../shared/issue-ref.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -500,16 +500,6 @@ export function install(rt) { } } - // #1331: a card belongs to the issue its own fields name. `title`/`target` name it; a note that - // merely mentions the id ("blocked on #1234 first") does not, or a dispatch for #1234 rewrites the - // #999 card and leaves #1234 with no card at all. The note is still read for a card that names no - // issue anywhere else, so #1254's "one issue is one card" holds for cards written from a note. - const cardNamesIssue = (task, wanted) => { - const own = `${task.title ?? ''} ${task.target?.href ?? ''}` - if (namesIssue(own, wanted)) return true - return issueRefs(own).length === 0 && namesIssue(task.note ?? '', wanted) - } - // #1254: the room's task lead naming one member with an issue id and an imperative is an assign, // not a round (`groups.js` `dispatchOf`). The card that member already holds for that issue is // updated; otherwise one is opened, owned by that member and still `open` — the host assigns, and diff --git a/patches/ds-bot/files/src/host/work-dispatch.js b/patches/ds-bot/files/src/host/work-dispatch.js index 636fcbf54..0f9e75f19 100644 --- a/patches/ds-bot/files/src/host/work-dispatch.js +++ b/patches/ds-bot/files/src/host/work-dispatch.js @@ -3,7 +3,10 @@ // that member's card and the drive wakes it in its own chat (#1205); no group part opens. A // question, a remark, a line naming two members or an `@all` keeps the group turn it had — this // module only answers the shape of one line, the room decides who may dispatch. -const ISSUE = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi +import { issueRefPattern } from '../shared/issue-ref.js' + +export { cardNamesIssue, issueRefs, namesIssue } from '../shared/issue-ref.js' + const MENTION = /@[^\s@,,。]{1,40}/g const QUESTION = /[??]/ // An imperative counts only near the id: "rebase #1234" and "#1234 rebase and push" both dispatch, @@ -16,22 +19,6 @@ const oneLine = value => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' // knows who was named. const withoutMentions = text => String(text ?? '').replace(MENTION, ' ').replace(/\s+/g, ' ').trim() -// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. -export function issueRefs(text) { - const keys = [] - for (const match of String(text ?? '').matchAll(new RegExp(ISSUE.source, 'gi'))) { - const key = `#${match[1] ?? match[2]}` - if (!keys.includes(key)) keys.push(key) - } - return keys -} - -export function namesIssue(text, issue) { - const number = String(issue ?? '').replace(/^#/, '') - if (!/^\d+$/.test(number)) return false - return issueRefs(text).includes(`#${number}`) -} - // One member, one issue id, one imperative near it. Anything else is not this module's to convert. export function workDispatch(text, { callees = [] } = {}) { if (!Array.isArray(callees) || callees.length !== 1) return undefined @@ -39,7 +26,7 @@ export function workDispatch(text, { callees = [] } = {}) { if (!callee?.id) return undefined const line = withoutMentions(text) if (line === '' || QUESTION.test(line)) return undefined - for (const match of line.matchAll(new RegExp(ISSUE.source, 'gi'))) { + for (const match of line.matchAll(issueRefPattern())) { const near = line.slice(Math.max(0, match.index - NEAR), match.index + match[0].length + NEAR) if (!IMPERATIVE.test(near)) continue const issue = `#${match[1] ?? match[2]}` diff --git a/patches/ds-bot/files/src/shared/issue-ref.js b/patches/ds-bot/files/src/shared/issue-ref.js new file mode 100644 index 000000000..ee1cbd167 --- /dev/null +++ b/patches/ds-bot/files/src/shared/issue-ref.js @@ -0,0 +1,31 @@ +// One way to read an issue or pull-request number out of text (#1254, #1331, #1360). +// The dispatch, the task store and the board all call these. A second pattern would +// hang a record the host does not treat as that issue's card. + +const ISSUE_REF = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi + +// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. +export function issueRefs(text) { + const keys = [] + for (const match of String(text ?? '').matchAll(new RegExp(ISSUE_REF.source, 'gi'))) { + const key = `#${match[1] ?? match[2]}` + if (!keys.includes(key)) keys.push(key) + } + return keys +} + +export function namesIssue(text, issue) { + const number = String(issue ?? '').replace(/^#/, '') + if (!/^\d+$/.test(number)) return false + return issueRefs(text).includes(`#${number}`) +} + +// A card belongs to the issue its own fields name. `title` and `target` name it; a note +// that merely mentions the id does not, unless the card names no issue anywhere else. +export function cardNamesIssue(task, wanted) { + const own = `${task?.title ?? ''} ${task?.target?.href ?? ''}` + if (namesIssue(own, wanted)) return true + return issueRefs(own).length === 0 && namesIssue(task?.note ?? '', wanted) +} + +export const issueRefPattern = () => new RegExp(ISSUE_REF.source, 'gi') diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index 6926a01ab..ea2dbb171 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -860,3 +860,155 @@ test('a workflow draws its own columns, named by the workflow, and its card once const ready = legacy.find(node => node.props['data-column'] === 'ready') assert.equal(walk(ready).some(node => node.props?.['data-number'] === 21), false) }) + +// #1360: a direct child of the column is a top-level card. A nested record sits inside +// `bt-board-nest` and must not count as a sibling of the issue. +const topLevel = (column) => (column?.children ?? []).filter(node => + node && (node.type === 'article' || node.props?.className === 'bt-board-nest')) +const columnOf = (board, id) => walk(board).find(node => node.type === 'section' && node.props?.['data-column'] === id && node.props['data-workflow'] === undefined) + +const issueCard = (repo, number, extra = {}) => ({ + kind: 'issue', number, title: `Issue ${number}`, labels: ['agent-working'], repo, + url: `https://github.com/${repo}/issues/${number}`, ...extra, +}) +const pullCard = (repo, number) => ({ + kind: 'pr', number, title: `PR ${number}`, labels: [], repo, stage: 'review', badge: 'inc', + url: `https://github.com/${repo}/pull/${number}`, sha: 'abcdef1', +}) + +test('#1360: a task that names an issue hangs under that card, and the linked PR is not a third sibling', () => { + const moved = [] + const tasks = [ + { id: 'task-new', title: 'acme/board#12 implement', status: 'working', ownerBotId: 'bot', updatedAt: 5, note: 'in flight' }, + { id: 'task-old', title: '#12 the earlier card', status: 'working', ownerBotId: 'bot', updatedAt: 1 }, + { id: 'task-note', title: 'follow up', note: 'see #12', status: 'claimed', ownerBotId: 'bot', updatedAt: 4 }, + { id: 'task-other', title: '#99 elsewhere', note: 'blocked on #12 first', status: 'open', ownerBotId: 'bot' }, + { id: 'task-orphan', title: 'Write the weekly note', status: 'open', ownerBotId: 'bot' }, + ] + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks, + roster: { byId: { bot: { name: 'Dev4' } } }, + onMove: (id, column) => moved.push([id, column]), + }) + const progress = columnOf(board, 'in-progress') + const rows = topLevel(progress) + assert.equal(rows.length, 1, 'one top-level row: the issue, not the task and the PR beside it') + assert.equal(rows[0].props.className, 'bt-board-nest') + const issue = walk(rows[0]).find(node => node.props?.['data-number'] === 12) + assert.equal(issue.props['data-kind'], 'issue') + assert.equal(issue.props['data-key'], 'acme/board#12') + const sub = walk(rows[0]).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(sub.props['data-parent'], 'acme/board#12') + const hung = walk(sub).find(node => node.props?.['data-task']) + // Newest open record that names the issue. The older open card is not drawn again, and a note + // that names #12 does not beat a title that names no issue of its own when another card does — + // task-note's own fields name nothing, so it also matches, but it is older than task-new. + assert.equal(hung.props['data-task'], 'task-new') + assert.equal(walk(board).filter(node => node.props?.['data-task'] === 'task-old').length, 0, 'the older open card is not a second record') + assert.equal(walk(board).some(node => node.props?.['data-number'] === 41 && node.type === 'article' && !walk(rows[0]).includes(node)), false) + assert.equal(topLevel(progress).some(node => node.props?.['data-number'] === 41), false, 'the linked PR is not a sibling') + assert.match(text(hung), /Dev4/) + const complete = walk(sub).find(node => node.type === 'button' && text(node).includes('Complete')) + assert.ok(complete, 'the nested record still offers Complete') + complete.props.onClick() + assert.deepEqual(moved, [['task-new', 'done']]) + // The orphan stays its own card. A title that names a different issue, with #12 only in the note, + // is not this issue's record (#1331) and stays top-level because #99 is not on the board. + const todo = columnOf(board, 'not-started') + const loose = topLevel(todo).map(node => node.props?.['data-task']).filter(Boolean).sort() + assert.deepEqual(loose, ['task-orphan', 'task-other']) + assert.equal(walk(todo).some(node => node.props?.className === 'bt-board-subtask'), false) + + // Deleting the issue link puts the record back beside the issue. The linked PR stays folded + // into the issue, so the column's top-level count is the issue plus the task. + const unlinked = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks: [{ id: 'task-free', title: 'implement the step', status: 'working', ownerBotId: 'bot' }], + roster: { byId: { bot: { name: 'Dev4' } } }, + }) + const again = columnOf(unlinked, 'in-progress') + assert.equal(topLevel(again).length, 2) + assert.equal(walk(again).some(node => node.props?.className === 'bt-board-subtask'), false) + assert.equal(walk(again).some(node => node.props?.['data-task'] === 'task-free'), true) + assert.equal(topLevel(again).some(node => node.props?.['data-number'] === 41), false) + + // A record that names the pull request hangs under the issue that pull request closes (#1189). + const viaPull = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12, { linked: 41 }), pullCard('acme/board', 41)], + done: [], + }, + }, + tasks: [{ id: 'task-pr', title: 'review #41', status: 'working', ownerBotId: 'bot' }], + }) + const under = walk(columnOf(viaPull, 'in-progress')).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(under?.props['data-parent'], 'acme/board#12') + assert.equal(walk(under).find(node => node.props?.['data-task'])?.props['data-task'], 'task-pr') + assert.equal(topLevel(columnOf(viaPull, 'in-progress')).length, 1) +}) + +test('#1360: a bare number nests only when one issue on the board has it; a named repo nests on that repo', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: null, asOf: '2026-10-27T06:00:00Z', + repos: [ + { repo: 'acme/one', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/one', 12)], done: [] } }, + { repo: 'acme/two', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/two', 12)], done: [] } }, + ], + }, + tasks: [ + { id: 'task-bare', title: '#12 ambiguous', status: 'working' }, + { id: 'task-one', title: 'land acme/one#12', status: 'working' }, + ], + }) + const progress = columnOf(board, 'in-progress') + const subs = walk(progress).filter(node => node.props?.className === 'bt-board-subtask') + assert.equal(subs.length, 1) + assert.equal(subs[0].props['data-parent'], 'acme/one#12') + assert.equal(walk(subs[0]).find(node => node.props?.['data-task']).props['data-task'], 'task-one') + assert.equal(walk(progress).filter(node => node.props?.['data-task'] === 'task-bare').length, 1, 'a bare #12 with two issues stays top-level') + assert.equal(walk(subs).some(node => node.props?.['data-task'] === 'task-bare'), false) +}) + +test('#1360: with no issue on the board, an orphan and a linked task both stay top-level', () => { + const tasks = [ + { id: 'task-orphan', title: 'Write the weekly note', status: 'open' }, + { id: 'task-linked', title: 'acme/board#12 do it', status: 'working', target: { owner: 'acme', repo: 'board', number: 12, href: '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/acme/board/issues/12' } }, + ] + const failed = DeliveryBoard({ + view: { state: 'failed', repo: 'acme/board', error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null }, retryAt: Date.now() + 10_000 }, + tasks, + onRetry: () => {}, + }) + const ids = walk(failed).filter(node => node.props?.['data-task']).map(node => node.props['data-task']).sort() + assert.deepEqual(ids, ['task-linked', 'task-orphan']) + assert.equal(walk(failed).some(node => node.props?.className === 'bt-board-subtask'), false) + // The issue the task names is not in this snapshot, so the record is a card, not a blank. + const missing = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { 'not-started': [issueCard('acme/board', 8)], 'in-progress': [], done: [] }, + }, + tasks: [tasks[1]], + }) + assert.equal(walk(missing).some(node => node.props?.className === 'bt-board-subtask'), false) + assert.equal(walk(missing).filter(node => node.props?.['data-task'] === 'task-linked').length, 1) +}) From 3396b0d0548250f6eed8640447b74ee3699d4ba4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 06:58:17 +0000 Subject: [PATCH 2/2] Match issue nesting to cardNamesIssue A note no longer chooses the repository when the title already names an issue. The board asks issueCardForTask, which reads the same text as cardNamesIssue. Fixes #1360 Part of #1265 Co-authored-by: Danyang Chen --- core/client.js | 78 +- core/src/client/delivery-view.js | 47 +- core/src/shared/issue-ref.js | 60 + core/test/delivery-view.test.js | 43 + docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 1449 +++++++++-------- .../ds-bot/files/src/client/delivery-view.js | 47 +- patches/ds-bot/files/src/shared/issue-ref.js | 60 + .../ds-bot/files/test/delivery-view.test.js | 43 + 10 files changed, 994 insertions(+), 837 deletions(-) diff --git a/core/client.js b/core/client.js index ec265c384..c5287d537 100644 --- a/core/client.js +++ b/core/client.js @@ -8462,6 +8462,49 @@ function cardNamesIssue(task, wanted) { return issueRefs(own).length === 0 && namesIssue(task?.note ?? "", wanted); } var issueRefPattern = () => new RegExp(ISSUE_REF.source, "gi"); +var MENTION = /(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#(\d{1,7})(?![0-9])|github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d{1,7})(?![0-9])|#(\d{1,7})(?![0-9])/gi; +var mentionOf = (match) => { + if (match[3]) return { repo: `${match[1]}/${match[2]}`, number: match[3] }; + if (match[6]) return { repo: `${match[4]}/${match[5]}`, number: match[6] }; + if (match[7]) return { repo: null, number: match[7] }; + return null; +}; +function cardMentions(task) { + const own = `${task?.title ?? ""} ${task?.target?.href ?? ""}`; + const text = issueRefs(own).length > 0 ? own : `${own} ${task?.note ?? ""}`; + const mentions = []; + const seen = /* @__PURE__ */ new Set(); + for (const match of text.matchAll(new RegExp(MENTION.source, "gi"))) { + const mention = mentionOf(match); + if (!mention) continue; + const key = `${mention.repo ?? ""}#${mention.number}`; + if (seen.has(key)) continue; + seen.add(key); + mentions.push(mention); + } + return mentions; +} +var asNumber = (value) => value == null || value === "" ? null : String(value); +function issueCardForTask(task, issues) { + const mentions = cardMentions(task); + if (mentions.length === 0) return null; + const list = (issues ?? []).filter((issue) => asNumber(issue?.card?.number ?? issue?.number) != null); + const numberOf = (issue) => asNumber(issue?.card?.number ?? issue?.number); + const linkedOf = (issue) => asNumber(issue?.card?.linked ?? issue?.linked); + const repoOf2 = (issue) => typeof issue?.repo === "string" ? issue.repo : ""; + const qualified = list.filter((issue) => mentions.some((mention) => mention.repo != null && mention.repo === repoOf2(issue) && (mention.number === numberOf(issue) || mention.number === linkedOf(issue)))); + if (qualified.length === 1) return qualified[0]; + if (qualified.length > 1) { + const byNumber2 = qualified.filter((issue) => mentions.some((mention) => mention.repo === repoOf2(issue) && mention.number === numberOf(issue))); + return byNumber2.length === 1 ? byNumber2[0] : null; + } + const bare2 = new Set(mentions.filter((mention) => mention.repo == null).map((mention) => mention.number)); + if (bare2.size === 0) return null; + const bareHits = list.filter((issue) => bare2.has(numberOf(issue)) || linkedOf(issue) != null && bare2.has(linkedOf(issue))); + if (bareHits.length === 1) return bareHits[0]; + const byNumber = bareHits.filter((issue) => bare2.has(numberOf(issue))); + return byNumber.length === 1 ? byNumber[0] : null; +} // src/client/delivery-view.js var import_react16 = require("react"); @@ -8864,20 +8907,6 @@ var cardRepoOf = (card, fallback) => { return typeof fallback === "string" ? fallback.trim() : ""; }; var recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}`; -function namedRepos(task) { - if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`]; - const text = `${task?.title ?? ""} -${task?.note ?? ""}`; - const repos = []; - for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`); - for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`); - return repos; -} -var repoOk = (task, repo) => { - const named = namedRepos(task); - if (named.length === 0 || repo === "") return true; - return named.includes(repo); -}; function hangRecords(columns, tasks, repo) { const issues = []; for (const cards of Object.values(columns ?? {})) { @@ -8890,26 +8919,7 @@ function hangRecords(columns, tasks, repo) { const taken = /* @__PURE__ */ new Set(); const open = (tasks ?? []).filter((task) => task && task.kind !== "group" && !TERMINAL_TASK.has(task.status) && task.id != null); for (const task of open) { - let best = null; - let bestScore = -1; - const hits = []; - for (const issue of issues) { - if (!repoOk(task, issue.repo)) continue; - const namesIssue2 = cardNamesIssue(task, issue.card.number); - const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked); - if (!namesIssue2 && !namesPull) continue; - hits.push(issue); - } - const bare2 = namedRepos(task).length === 0; - const pool = bare2 && hits.length > 1 ? [] : hits; - for (const issue of pool) { - const target = Number(task.target?.number); - const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0; - if (score > bestScore) { - best = issue; - bestScore = score; - } - } + const best = issueCardForTask(task, issues); if (!best) continue; const key = recordKey(best.repo, best.card.number); const list = nested.get(key) ?? []; diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 025dc4949..db39091b7 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -14,7 +14,7 @@ import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard, githubCardKey } from '../shared/board-card.js' -import { cardNamesIssue } from '../shared/issue-ref.js' +import { issueCardForTask } from '../shared/issue-ref.js' import { BoardCard, DELIVERY_SKIN } from './board-card.js' import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' @@ -450,28 +450,10 @@ const cardRepoOf = (card, fallback) => { const recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}` -// The repository a record already names, read off the same text `cardNamesIssue` matches. -// A bare `#12` names no repository. `target` is the host's normalised pointer and wins over a -// mention in the title, which is how a dispatch and the board stay on one card. -function namedRepos(task) { - if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`] - const text = `${task?.title ?? ''}\n${task?.note ?? ''}` - const repos = [] - for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`) - for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`) - return repos -} - -const repoOk = (task, repo) => { - const named = namedRepos(task) - if (named.length === 0 || repo === '') return true - return named.includes(repo) -} - -// A record hangs under the issue `cardNamesIssue` would hand a dispatch (#1254, #1331). The -// issue's `linked` pull request is the closing-keyword link the snapshot already computed (#1189), -// so a record that names that pull request hangs under the issue too. One open record per issue; -// an older open record that names the same issue is not drawn again. +// A record hangs under the issue `issueCardForTask` names (#1254, #1331, #1360). That is +// `cardNamesIssue` on the issue number or on the closing-keyword pull request the snapshot +// already stored as `linked` (#1189), and a repository only when the same text writes one. +// One open record per issue; an older open record that names the same issue is not drawn again. export function hangRecords(columns, tasks, repo) { const issues = [] for (const cards of Object.values(columns ?? {})) { @@ -484,24 +466,7 @@ export function hangRecords(columns, tasks, repo) { const taken = new Set() const open = (tasks ?? []).filter(task => task && task.kind !== 'group' && !TERMINAL_TASK.has(task.status) && task.id != null) for (const task of open) { - let best = null - let bestScore = -1 - const hits = [] - for (const issue of issues) { - if (!repoOk(task, issue.repo)) continue - const namesIssue = cardNamesIssue(task, issue.card.number) - const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked) - if (!namesIssue && !namesPull) continue - hits.push(issue) - } - // A bare `#12` on a board where two repositories both have that issue names neither. - const bare = namedRepos(task).length === 0 - const pool = bare && hits.length > 1 ? [] : hits - for (const issue of pool) { - const target = Number(task.target?.number) - const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0 - if (score > bestScore) { best = issue; bestScore = score } - } + const best = issueCardForTask(task, issues) if (!best) continue const key = recordKey(best.repo, best.card.number) const list = nested.get(key) ?? [] diff --git a/core/src/shared/issue-ref.js b/core/src/shared/issue-ref.js index ee1cbd167..48316b46b 100644 --- a/core/src/shared/issue-ref.js +++ b/core/src/shared/issue-ref.js @@ -29,3 +29,63 @@ export function cardNamesIssue(task, wanted) { } export const issueRefPattern = () => new RegExp(ISSUE_REF.source, 'gi') + +// The same mentions as ISSUE_REF, with the repository when the text writes one beside the number +// (`owner/repo#12`, or a github.com issues/pull URL). A bare `#12` carries no repository. The +// number is the same one `issueRefs` returns; the repository is not a second decision. +const MENTION = /(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#(\d{1,7})(?![0-9])|github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d{1,7})(?![0-9])|#(\d{1,7})(?![0-9])/gi + +const mentionOf = (match) => { + if (match[3]) return { repo: `${match[1]}/${match[2]}`, number: match[3] } + if (match[6]) return { repo: `${match[4]}/${match[5]}`, number: match[6] } + if (match[7]) return { repo: null, number: match[7] } + return null +} + +// Mentions in the text `cardNamesIssue` consults: title and `target.href`, and the note only +// when those name no issue. A note cannot attach a repository to a number the title already named. +export function cardMentions(task) { + const own = `${task?.title ?? ''} ${task?.target?.href ?? ''}` + const text = issueRefs(own).length > 0 ? own : `${own} ${task?.note ?? ''}` + const mentions = [] + const seen = new Set() + for (const match of text.matchAll(new RegExp(MENTION.source, 'gi'))) { + const mention = mentionOf(match) + if (!mention) continue + const key = `${mention.repo ?? ''}#${mention.number}` + if (seen.has(key)) continue + seen.add(key) + mentions.push(mention) + } + return mentions +} + +const asNumber = (value) => (value == null || value === '' ? null : String(value)) + +// The one issue card this task hangs under, or null when `cardNamesIssue` names none or names +// more than one. `issues` are `{ repo, number, linked }` or `{ repo, card: { number, linked } }`. +// A repository written next to the number selects that card; a bare number selects the only +// card with that number (or the only card whose linked pull request has it). +export function issueCardForTask(task, issues) { + const mentions = cardMentions(task) + if (mentions.length === 0) return null + const list = (issues ?? []).filter((issue) => asNumber(issue?.card?.number ?? issue?.number) != null) + const numberOf = (issue) => asNumber(issue?.card?.number ?? issue?.number) + const linkedOf = (issue) => asNumber(issue?.card?.linked ?? issue?.linked) + const repoOf = (issue) => (typeof issue?.repo === 'string' ? issue.repo : '') + const qualified = list.filter((issue) => mentions.some((mention) => + mention.repo != null && mention.repo === repoOf(issue) + && (mention.number === numberOf(issue) || mention.number === linkedOf(issue)))) + if (qualified.length === 1) return qualified[0] + if (qualified.length > 1) { + const byNumber = qualified.filter((issue) => mentions.some((mention) => + mention.repo === repoOf(issue) && mention.number === numberOf(issue))) + return byNumber.length === 1 ? byNumber[0] : null + } + const bare = new Set(mentions.filter((mention) => mention.repo == null).map((mention) => mention.number)) + if (bare.size === 0) return null + const bareHits = list.filter((issue) => bare.has(numberOf(issue)) || (linkedOf(issue) != null && bare.has(linkedOf(issue)))) + if (bareHits.length === 1) return bareHits[0] + const byNumber = bareHits.filter((issue) => bare.has(numberOf(issue))) + return byNumber.length === 1 ? byNumber[0] : null +} diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index ea2dbb171..6473b3e7a 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -1012,3 +1012,46 @@ test('#1360: with no issue on the board, an orphan and a linked task both stay t assert.equal(walk(missing).some(node => node.props?.className === 'bt-board-subtask'), false) assert.equal(walk(missing).filter(node => node.props?.['data-task'] === 'task-linked').length, 1) }) + +// #1360 review: the note is not a second matcher. `cardNamesIssue` reads it only when the title +// and target name no issue, so a repository written only in the note cannot move the card. +test('#1360: a title that names #12 nests under that issue even when the note names another repository', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12)], + done: [], + }, + }, + tasks: [{ + id: 'task-title', + title: '#12 implement', + note: 'blocked on ThinkFlowLab/FlyBot#9', + status: 'working', + }], + }) + const progress = columnOf(board, 'in-progress') + assert.equal(topLevel(progress).length, 1) + const sub = walk(progress).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(sub?.props['data-parent'], 'acme/board#12') + assert.equal(walk(sub).find(node => node.props?.['data-task'])?.props['data-task'], 'task-title') +}) + +test('#1360: a bare #12 stays top-level when two issues share it, even if the note names one repository', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: null, asOf: '2026-10-27T06:00:00Z', + repos: [ + { repo: 'acme/one', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/one', 12)], done: [] } }, + { repo: 'acme/two', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/two', 12)], done: [] } }, + ], + }, + tasks: [{ id: 'task-bare-note', title: '#12', note: 'blocked on acme/two#99', status: 'working' }], + }) + const progress = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'] === 'in-progress' && node.props['data-workflow'] === undefined) + assert.equal(walk(board).some(node => node.props?.className === 'bt-board-subtask'), false) + const shown = progress.flatMap(column => walk(column).filter(node => node.props?.['data-task'] === 'task-bare-note')) + assert.equal(shown.length, 1, 'the bare number stays one top-level card') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index db04ce964..9299d8bf5 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,7 +38,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | -| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). #1360 hangs the open FlyBot record under the issue it names (`hangRecords`, the same `cardNamesIssue` predicate as a dispatch): the issue row is `div.bt-board-nest` and the record is `div.bt-board-subtask[data-parent=owner/repo#N]`, the same indent a group subtask uses. At most one open record per `owner/repo#N` (the newest). A record with no issue link, a bare `#N` that two repositories share, or an issue that is not on the board stays a top-level card. A pull request the issue's `linked` field already names is not a second card in that column; a stage filter that hides the issue still draws the pull request. With no columns (no token, a failed read) every record stays top-level. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | +| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). #1360 hangs the open FlyBot record under the issue it names (`hangRecords` calls `issueCardForTask` in `src/shared/issue-ref.js`, the same `cardNamesIssue` text as a dispatch: title and `target.href`, and the note only when those name no issue, so a note cannot choose the repository): the issue row is `div.bt-board-nest` and the record is `div.bt-board-subtask[data-parent=owner/repo#N]`, the same indent a group subtask uses. At most one open record per `owner/repo#N` (the newest). A record with no issue link, a bare `#N` that two repositories share, or an issue that is not on the board stays a top-level card. A pull request the issue's `linked` field already names is not a second card in that column; a stage filter that hides the issue still draws the pull request. With no columns (no token, a failed read) every record stays top-level. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | | `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` — the same key for an issue and the pull request that closes it, which is the identity step 3 hangs a FlyBot record under — and `taskCardKey(id)` is `task:`. `githubCard(row, repo)` and `taskCard(task)` build the model the component draws, `sameCard(a, b)` is the one comparison both sides dedupe by, and a GitHub row with no repository falls back to `github::` rather than imply an identity across repositories. Tests: `core/test/board-card.test.js`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 314c2b24a..6d3c7ba93 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -53,7 +53,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` and `taskCardKey(id)` is `task:`, the two spellings the client's `BoardCard` puts on every card. The GitHub key is not keyed by kind — an issue and the pull request that closes it are one card — which is the identity step 3 hangs a FlyBot task record under; a row with no repository falls back to `github::` rather than imply a cross-repository identity. `githubCard(row, repo)` / `taskCard(task)` build the model, `sameCard(a, b)` is the one comparison. Tests: `core/test/board-card.test.js`. | -| `core/src/shared/issue-ref.js` | The one issue/PR matcher (#1254, #1331, #1360): `issueRefs`, `namesIssue` and `cardNamesIssue`. Own fields (`title`, `target.href`) name the card; a note counts only when those name no issue. `work-dispatch.js` re-exports them and `tasks.js` `assignFromDispatch` uses `cardNamesIssue` to pick the newest open record. The board calls the same function, so a nested record is the card the host would update. Tests: `core/test/work-dispatch.test.js`, `core/test/delivery-view.test.js`. | +| `core/src/shared/issue-ref.js` | The one issue/PR matcher (#1254, #1331, #1360): `issueRefs`, `namesIssue`, `cardNamesIssue` and `issueCardForTask`. Own fields (`title`, `target.href`) name the card; a note counts only when those name no issue, and a repository written in that note does not count either. `work-dispatch.js` re-exports the first three and `tasks.js` `assignFromDispatch` uses `cardNamesIssue` to pick the newest open record. The board calls `issueCardForTask`, so the nested record is the card the host would update. Tests: `core/test/work-dispatch.test.js`, `core/test/delivery-view.test.js`. | | `core/src/shared/workflow.js` | The workflow engine's pure half (#1272): a workflow is data — `nodes` (each with a required `role` and typed `inputs` / `outputs`), `edges` (`. -> .`) and declared `loops` — and every function below is a function of that file. `validateWorkflow` refuses each way a definition cannot run, with its own sentence (an input nothing produces, an output nothing consumes and that is not a `deliverable`, two producers of one fact, an undeclared cycle, a node with no `role`, a role not on the team, an unknown artifact kind, a misaligned edge, a guard whose `is` is not one of `present` / `fresh` / `equals`, a `column` the board does not have); `workflowColumns` is the board's columns in dependency order, so the node ids are the state and no built-in list exists, and a node carrying `column: false` draws no column at all while a definition that states none keeps the node id as its column (#1265); `layoutWorkflow` is the flowchart (one box per node carrying its role and rails, one edge per `edges[]` row, ranks left to right); `enterNode` / `inputStates` / `renderRefusal` answer whether a card may start and, when it may not, say which input is missing or stale with both revisions, in the node's own `refusal` wording when it has one and in the guard's own wording when that guard carries one; both read a fact by the same parsed key the rest of the engine uses, so the spelling on the card and the spelling on the producer cannot disagree (#1298); `holderOf` is the node's role, which is the card's assignee. #1291's four debts are closed here: one word for one thing (`role`, never `bot`), an unknown guard `is` refuses instead of being read as `present`, `producersOf` / `consumersOf` key on the parsed spec so `text: draft` and `text:draft` are one fact, and `resolvedEdges` reports the artifact it carries once. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-blank.js` | The `blank` preset (#1272): three nodes (`draft` / `review` / `publish`, roles `Writer` / `Editor` / `Publisher`), no guards, artifacts `text:topic -> text:draft -> text:edited -> link:published`. Data, not logic — the same JSON is what a workspace `workflows/blank.json` holds — and the proof that the engine special-cases no node id. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-repo-dev.js` | The `repo-dev` preset (#1272 step 2): one repository change as data — `triage` → `build` → `verify` → `review` → `gate` → `merge` → `release`, roles `Triage` / `Dev` / `SelfTest` / `Reviewer` / `system` / `Release`, and the artifacts `issue` → `issue.ready` → `pr` → `check:tests-ok` → `verdict` → `risk:tier` → `merge-commit` → `release`. Two roles say what they are rather than who: `verify` is `SelfTest` **and** CI, the run being the `check:tests-ok` fact it hands out, and `gate` is held by the Reviewer that judged the pull request, read from the verdict's own `Written-by` through a guard. The gate's guards refuse in the merge gate's own words against `.github/scripts/fixtures/workflow-cases.json`, which no file imports and both sides read. `column` per node: `not-started` / `in-progress` / `done`, with `review` on `false` — a review is the pull request's state, not a third lane (#1265). Tests: `core/test/workflow-repo-dev.test.js`. | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 0991e946e..e3db1391d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -4456,7 +4456,7 @@ diff --git a/client.js b/client.js .bt-states{display:flex;flex-wrap:wrap;gap:4px} .bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} .bt-state:hover{color:var(--bt-ink)} -@@ -2488,2292 +2488,5501 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,2292 +2488,5511 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -8591,6 +8591,7 @@ diff --git a/client.js b/client.js - item(t("Copy"), CopyIcon, copy2), - item(t("Download"), DownloadIcon, download2) - ), document.body) : null +- ); +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -8598,7 +8599,13 @@ diff --git a/client.js b/client.js + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; -+} + } +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) + +// src/client/member-status.js +var import_react12 = require("react"); @@ -8654,24 +8661,6 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ); } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) -+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -+function WorkRow({ bot, tool, step, label, at, now, onOpen }) { -+ const text = `${label} · ${workText(tool, step)}`; -+ const elapsed = elapsedSince(now, at); -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), -+ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } -function useFileMentions({ node, openFile, fileMentions, useTurnData }) { - const tail = useTurnData?.("turn-tail"); - const location2 = node.location; @@ -8681,26 +8670,20 @@ diff --git a/client.js b/client.js - if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; - return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; - }, [fileMentions, openFile, seq, tail, turn]); -+function SummaryLine({ busy, now, onOpen }) { -+ const { waiting, doing } = summaryText(busy); -+ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; ++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; ++function WorkRow({ bot, tool, step, label, at, now, onOpen }) { ++ const text = `${label} · ${workText(tool, step)}`; ++ const elapsed = elapsedSince(now, at); + return (0, import_react12.createElement)( + "button", -+ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), -+ (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-row-text" }, -+ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, -+ waiting && doing ? " · " : null, -+ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null -+ ), ++ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), ++ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); } -function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+function MemberDock({ sessionId, useRoster, useWork, actions }) { - const roster = useRoster((value) => value); +- const roster = useRoster((value) => value); - const data = node.data; - const steps = useTurnSteps(String(data.turn)); - const contexts = useTurnContext(String(data.turn)); @@ -8732,7 +8715,28 @@ diff --git a/client.js b/client.js - }), - reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, - texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -- ); ++function SummaryLine({ busy, now, onOpen }) { ++ const { waiting, doing } = summaryText(busy); ++ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; ++ return (0, import_react12.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), ++ (0, import_react12.createElement)( ++ "span", ++ { className: "bt-mw-row-text" }, ++ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, ++ waiting && doing ? " · " : null, ++ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null ++ ), ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); + } +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( ++function MemberDock({ sessionId, useRoster, useWork, actions }) { ++ const roster = useRoster((value) => value); + const work = useWork((value) => value); + const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; @@ -8748,12 +8752,32 @@ diff --git a/client.js b/client.js + const { rows, rest } = splitBusy(busy); + const open = () => actions.showDetails(sessionId); + return (0, import_react12.createElement)( -+ "div", + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) + { ref, className: "bt-mw-dock-stack" }, + rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null -+ ); -+ } + ); + } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react12.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react12.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -8782,32 +8806,23 @@ diff --git a/client.js b/client.js + return () => observer.disconnect(); + }, [active, key]); } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +- "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) +- ); +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -8817,12 +8832,7 @@ diff --git a/client.js b/client.js + }, [active]); + return now; } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { - return (0, import_react6.createElement)( +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ @@ -8837,14 +8847,9 @@ diff --git a/client.js b/client.js + }; + return (0, import_react12.createElement)( "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, @@ -8863,7 +8868,14 @@ diff --git a/client.js b/client.js + ) ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- 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 }); - return (0, import_react6.createElement)( +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react12.useState)(false); @@ -8883,8 +8895,7 @@ diff --git a/client.js b/client.js + return (0, import_react12.createElement)( "div", - { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) + { className: "bt-mw-member" }, + (0, import_react12.createElement)( + "div", @@ -8894,27 +8905,19 @@ diff --git a/client.js b/client.js + ) ); } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- 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 }); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) -- ); +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +- } +- return null; +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -8957,7 +8960,26 @@ diff --git a/client.js b/client.js +var line = (d) => (0, import_react13.createElement)("path", { d }); +var icon = (...paths) => function StatusIcon() { + return (0, import_react13.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; +var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); +var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); @@ -9001,59 +9023,168 @@ diff --git a/client.js b/client.js + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS2); -+ }); + }); +- }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); + load(); -+ return () => { + return () => { +- alive.current = false; + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); -+ }; -+ }, []); -+ return tasks; - } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -+var ownBot = (roster, id) => { -+ const bot = roster.byId?.[id]; -+ if (bot === void 0 || bot.remote) return void 0; -+ const me = roster.self?.memberId; -+ return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; -+}; -+var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); -+var roomName = (roster, id) => roster.roomsById?.[id]?.name; -+var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; -+var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; -+var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; -+var ownAskChat = (roster, bot, text) => { -+ if (bot === void 0 || typeof text !== "string" || text === "") return void 0; -+ for (const [key, question] of Object.entries(roster.questions ?? {})) { -+ if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; -+ if (question.question !== text) continue; -+ if (key === bot.id || key === currentPart(bot)) return bot.id; - } -- return null; -+ return void 0; - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); +- } +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; +- } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) +- ); ++ }, []); ++ return tasks; + } +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- 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)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- 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), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++var ownBot = (roster, id) => { ++ const bot = roster.byId?.[id]; ++ if (bot === void 0 || bot.remote) return void 0; ++ const me = roster.self?.memberId; ++ return me && bot.ownerId && bot.ownerId !== me ? void 0 : bot; ++}; ++var people = (roster, botId) => ({ assigner: roster.self?.memberId ?? "me", owner: roster.self?.memberId ?? "me", executingBot: botId ?? null }); ++var roomName = (roster, id) => roster.roomsById?.[id]?.name; ++var placeOf = (roster, roomId) => roomId && roomName(roster, roomId) ? t("in {name}", { name: roomName(roster, roomId) }) : ""; ++var lastWhy = (task) => [...task.actors ?? []].reverse().find((entry) => typeof entry.why === "string" && entry.why !== "")?.why ?? ""; ++var chatOfTask = (task) => task.origin?.roomId ?? task.origin?.sessionId ?? task.ownerBotId; ++var ownAskChat = (roster, bot, text) => { ++ if (bot === void 0 || typeof text !== "string" || text === "") return void 0; ++ for (const [key, question] of Object.entries(roster.questions ?? {})) { ++ if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; ++ if (question.question !== text) continue; ++ if (key === bot.id || key === currentPart(bot)) return bot.id; ++ } ++ return void 0; ++}; +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); @@ -9163,13 +9294,8 @@ diff --git a/client.js b/client.js + place: placeOf(roster, roomId), + chat: roomId ?? bot.id, + ...people(roster, bot.id) - }); - }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -- return () => { -- alive.current = false; ++ }); ++ }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -9209,69 +9335,14 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) - }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); ++ }; + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); + else if (task.status === "blocked") needs.push({ ...card, status: task.waitingFor === "input" ? "input" : "approval", reason: task.inputRequest?.question ?? "" }); + else if (task.status === "open" || task.status === "claimed" || task.status === "working") progress.push({ ...card, status: "task", since: task.createdAt }); + else if ((task.status === "accepted" || task.status === "cancelled") && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "accepted" ? "done" : "cancelled" }); - } -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; ++ } + if (sessions?.byId) { + for (const bot of roster.bots ?? []) { + if (ownBot(roster, bot.id) === void 0 || work.bots?.[bot.id] !== void 0 || waitingAt.has(`${bot.id}|`)) continue; @@ -9282,8 +9353,10 @@ diff --git a/client.js b/client.js + const text = previewOf(sessions, part); + if (text === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); -+ } -+ } + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); @@ -9319,7 +9392,9 @@ diff --git a/client.js b/client.js + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } + return void 0; -+} + } +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +var answerSlots = /* @__PURE__ */ new Map(); +var answerSlot = (id) => { + let slot = answerSlots.get(id); @@ -9373,19 +9448,36 @@ diff --git a/client.js b/client.js + } }, t("Send back")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) + ); -+} -+function AnswerFields({ card, busy, onAnswer }) { -+ const [, redraw] = (0, import_react13.useState)(0); -+ const question = card.kind === "question"; -+ const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; -+ if (!question && !taskInput) return null; -+ const slot = answerSlot(card.id); -+ const multi = question && card.question?.multiSelect === true; -+ const options = question && Array.isArray(card.question?.options) ? card.question.options : []; -+ const send = () => { -+ if (busy) return; -+ const custom = slot.text.trim(); -+ if (question) { + } +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- 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; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; ++function AnswerFields({ card, busy, onAnswer }) { ++ const [, redraw] = (0, import_react13.useState)(0); ++ const question = card.kind === "question"; ++ const taskInput = card.kind === "task" && card.status === "input" && typeof card.task?.inputRequest?.id === "string"; ++ if (!question && !taskInput) return null; ++ const slot = answerSlot(card.id); ++ const multi = question && card.question?.multiSelect === true; ++ const options = question && Array.isArray(card.question?.options) ? card.question.options : []; ++ const send = () => { ++ if (busy) return; ++ const custom = slot.text.trim(); ++ if (question) { + const selected = multi ? slot.picked.filter((label) => label !== "") : []; + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); @@ -9429,84 +9521,15 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); - } - }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ } ++ }), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); ++ ); } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +-function Nothing() { +- return null; +function InboxRow({ wide, useRoster, useWork, useUi, actions }) { - const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- 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)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- 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), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -- } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } ++ const roster = useRoster((value) => value); + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -9528,33 +9551,30 @@ diff --git a/client.js b/client.js + count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null + ); } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- 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; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); -+ } + } +- return next === void 0 ? void 0 : turns[next].first; + return typeof card.at === "number" ? relativeShort(card.at) : ""; +} +function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { @@ -9617,8 +9637,14 @@ diff --git a/client.js b/client.js + ) + ); } --function Nothing() { -- return null; +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +function useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9627,16 +9653,29 @@ diff --git a/client.js b/client.js + }, [ms]); + return now; } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9714,15 +9753,14 @@ diff --git a/client.js b/client.js + )) + ) + ) -+ ); + ); } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -- } -- return next === void 0 ? void 0 : turns[next].first; +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-} +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9785,56 +9823,104 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" -+}; -+function columnOf(task) { -+ return COLUMN_OF[task?.status] ?? "not-started"; -+} -+function priorityOf(task) { -+ return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; -+} -+var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; -+function workBucket(task) { -+ if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; -+ if (task?.status === "blocked" || task?.status === "result-ready") return 1; -+ return 2; -+} -+function compareForWork(a, b) { -+ const byBucket = workBucket(a) - workBucket(b); -+ if (byBucket !== 0) return byBucket; -+ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); -+ if (byPriority !== 0) return byPriority; -+ const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); -+ if (byWorking !== 0) return byWorking; -+ return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); -+} -+function sameRole(a, b) { -+ const left = String(a ?? "").trim().toLowerCase(); -+ const right = String(b ?? "").trim().toLowerCase(); -+ return left !== "" && left === right; -+} -+function compareForQueue(a, b) { -+ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); -+ if (byPriority !== 0) return byPriority; -+ return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); -+} -+function progressOf(task) { -+ const value = task?.progress; -+ if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; -+ return Math.max(0, Math.min(100, Math.round(value))); - } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+ -+// src/shared/board-card.js -+var CARD_SOURCES = ["github", "task"]; -+function githubCardKey(repo, number) { -+ const name = typeof repo === "string" ? repo.trim() : ""; + }; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +- } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +- } ++function columnOf(task) { ++ return COLUMN_OF[task?.status] ?? "not-started"; ++} ++function priorityOf(task) { ++ return PRIORITIES.includes(task?.priority) ? task.priority : "normal"; ++} ++var PRIORITY_RANK = { urgent: 0, high: 1, normal: 2, low: 3 }; ++function workBucket(task) { ++ if (task?.status === "open" || task?.status === "claimed" || task?.status === "working") return 0; ++ if (task?.status === "blocked" || task?.status === "result-ready") return 1; ++ return 2; ++} ++function compareForWork(a, b) { ++ const byBucket = workBucket(a) - workBucket(b); ++ if (byBucket !== 0) return byBucket; ++ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); ++ if (byPriority !== 0) return byPriority; ++ const byWorking = (a?.status === "working" ? 0 : 1) - (b?.status === "working" ? 0 : 1); ++ if (byWorking !== 0) return byWorking; ++ return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); ++} ++function sameRole(a, b) { ++ const left = String(a ?? "").trim().toLowerCase(); ++ const right = String(b ?? "").trim().toLowerCase(); ++ return left !== "" && left === right; ++} ++function compareForQueue(a, b) { ++ const byPriority = (PRIORITY_RANK[priorityOf(a)] ?? 2) - (PRIORITY_RANK[priorityOf(b)] ?? 2); ++ if (byPriority !== 0) return byPriority; ++ return (a?.createdAt ?? 0) - (b?.createdAt ?? 0); ++} ++function progressOf(task) { ++ const value = task?.progress; ++ if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; ++ return Math.max(0, Math.min(100, Math.round(value))); + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++ ++// src/shared/board-card.js ++var CARD_SOURCES = ["github", "task"]; ++function githubCardKey(repo, number) { ++ const name = typeof repo === "string" ? repo.trim() : ""; + const value = Number(number); + if (name === "" || !Number.isInteger(value) || value <= 0) return null; + return `${name}#${value}`; @@ -9864,30 +9950,13 @@ diff --git a/client.js b/client.js + url: row.url ?? null + }; } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -- ); +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); +function taskCard(task) { + const key = taskCardKey(task?.id); + if (key === null) return null; @@ -9907,15 +9976,10 @@ diff --git a/client.js b/client.js + sha: null, + url: null + }; - } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); ++} +function sameCard(a, b) { + return Boolean(a?.key) && a.key === b?.key; - } --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] ++} + +// src/client/board-card.js +var import_react14 = require("react"); @@ -9930,59 +9994,7 @@ diff --git a/client.js b/client.js + owner: "bt-del-owner", + linked: "bt-del-linked", + sha: "bt-del-sha" - }; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -- } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -- } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; ++}; +var TASK_SKIN = { + card: "bt-board-card", + top: "bt-board-card-top", @@ -10047,7 +10059,29 @@ diff --git a/client.js b/client.js + footer, + actions + ); -+} + } +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +- if (done) return void 0; +- let stop = false; +- let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +- }; +- void tick(); +- return () => { +- stop = true; +- clearTimeout(timer); + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -10121,7 +10155,14 @@ diff --git a/client.js b/client.js + state, + title: pointTitle(day, key), + value -+ }; + }; +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; + }); + const segments = []; + let run = null; @@ -10132,10 +10173,15 @@ diff --git a/client.js b/client.js + run = null; + } + continue; -+ } + } + if (!run) run = { dots: [] }; + run.dots.push(dot); } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( + if (run) segments.push(run); + return { + width, @@ -10149,10 +10195,7 @@ diff --git a/client.js b/client.js + today: segment.dots.some((dot) => dot.today) + })) + }; - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++} +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10186,20 +10229,73 @@ diff --git a/client.js b/client.js + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) + ); - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); ++} +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); + const fewest = short.length ? Math.min(...short) : 0; + const head = (0, import_react15.createElement)( -+ "div", + "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); +- } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); +- } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); +- } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( +- "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); +- }) + { className: "bt-del-dm-head", "data-flybot": "delivery-metrics-head" }, + (0, import_react15.createElement)("span", { className: "bt-del-dm-label" }, ""), + (0, import_react15.createElement)("span", { className: "bt-del-dm-chart-head" }, ""), @@ -10209,57 +10305,30 @@ diff --git a/client.js b/client.js + const body = rows.map((row, index) => { + const key = CHART_KEYS.includes(row?.key) ? row.key : null; + const chart = days && key ? (0, import_react15.createElement)(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) : null; -+ const counted = key !== null && COUNTING_KEYS.includes(key); -+ const star = days && counted && knownDays(days, key) < days.length ? "*" : ""; -+ const cells = [ -+ (0, import_react15.createElement)("span", { key: "label", className: "bt-del-dm-label" }, row?.label ?? ""), -+ // A median row gets no chart cell at all: no line, no placeholder box, no total. -+ ...key ? [(0, import_react15.createElement)("span", { key: "chart", className: "bt-del-dm-chart" }, chart)] : [], -+ (0, import_react15.createElement)("span", { key: "today", className: "bt-del-dm-value bt-del-dm-today" }, row?.today ?? "—"), -+ (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") -+ ]; -+ return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); -+ }); -+ return (0, import_react15.createElement)( -+ "div", -+ { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, -+ head, -+ body, -+ days ? null : (0, import_react15.createElement)("p", { className: "bt-del-chart-empty", "data-flybot": "delivery-chart-empty" }, t("No 7-day data yet.")), -+ days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null -+ ); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -- } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); -- return () => { -- stop = true; -- clearTimeout(timer); -- }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; -- } ++ const counted = key !== null && COUNTING_KEYS.includes(key); ++ const star = days && counted && knownDays(days, key) < days.length ? "*" : ""; ++ const cells = [ ++ (0, import_react15.createElement)("span", { key: "label", className: "bt-del-dm-label" }, row?.label ?? ""), ++ // A median row gets no chart cell at all: no line, no placeholder box, no total. ++ ...key ? [(0, import_react15.createElement)("span", { key: "chart", className: "bt-del-dm-chart" }, chart)] : [], ++ (0, import_react15.createElement)("span", { key: "today", className: "bt-del-dm-value bt-del-dm-today" }, row?.today ?? "—"), ++ (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") ++ ]; ++ return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); ++ }); ++ return (0, import_react15.createElement)( ++ "div", ++ { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, ++ head, ++ body, ++ days ? null : (0, import_react15.createElement)("p", { className: "bt-del-chart-empty", "data-flybot": "delivery-chart-empty" }, t("No 7-day data yet.")), ++ days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null + ); + } +-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +-function callTree(root) { +- const calls = []; +var DELIVERY_CHART_CSS = ` +.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} @@ -10291,12 +10360,7 @@ diff --git a/client.js b/client.js + for (const match of String(text ?? "").matchAll(new RegExp(ISSUE_REF.source, "gi"))) { + const key = `#${match[1] ?? match[2]}`; + if (!keys.includes(key)) keys.push(key); - } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( ++ } + return keys; +} +function namesIssue(text, issue) { @@ -10310,6 +10374,55 @@ diff --git a/client.js b/client.js + return issueRefs(own).length === 0 && namesIssue(task?.note ?? "", wanted); +} +var issueRefPattern = () => new RegExp(ISSUE_REF.source, "gi"); ++var MENTION = /(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#(\d{1,7})(?![0-9])|github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d{1,7})(?![0-9])|#(\d{1,7})(?![0-9])/gi; ++var mentionOf = (match) => { ++ if (match[3]) return { repo: `${match[1]}/${match[2]}`, number: match[3] }; ++ if (match[6]) return { repo: `${match[4]}/${match[5]}`, number: match[6] }; ++ if (match[7]) return { repo: null, number: match[7] }; ++ return null; ++}; ++function cardMentions(task) { ++ const own = `${task?.title ?? ""} ${task?.target?.href ?? ""}`; ++ const text = issueRefs(own).length > 0 ? own : `${own} ${task?.note ?? ""}`; ++ const mentions = []; + const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); ++ for (const match of text.matchAll(new RegExp(MENTION.source, "gi"))) { ++ const mention = mentionOf(match); ++ if (!mention) continue; ++ const key = `${mention.repo ?? ""}#${mention.number}`; ++ if (seen.has(key)) continue; ++ seen.add(key); ++ mentions.push(mention); ++ } ++ return mentions; ++} ++var asNumber = (value) => value == null || value === "" ? null : String(value); ++function issueCardForTask(task, issues) { ++ const mentions = cardMentions(task); ++ if (mentions.length === 0) return null; ++ const list = (issues ?? []).filter((issue) => asNumber(issue?.card?.number ?? issue?.number) != null); ++ const numberOf = (issue) => asNumber(issue?.card?.number ?? issue?.number); ++ const linkedOf = (issue) => asNumber(issue?.card?.linked ?? issue?.linked); ++ const repoOf2 = (issue) => typeof issue?.repo === "string" ? issue.repo : ""; ++ const qualified = list.filter((issue) => mentions.some((mention) => mention.repo != null && mention.repo === repoOf2(issue) && (mention.number === numberOf(issue) || mention.number === linkedOf(issue)))); ++ if (qualified.length === 1) return qualified[0]; ++ if (qualified.length > 1) { ++ const byNumber2 = qualified.filter((issue) => mentions.some((mention) => mention.repo === repoOf2(issue) && mention.number === numberOf(issue))); ++ return byNumber2.length === 1 ? byNumber2[0] : null; ++ } ++ const bare2 = new Set(mentions.filter((mention) => mention.repo == null).map((mention) => mention.number)); ++ if (bare2.size === 0) return null; ++ const bareHits = list.filter((issue) => bare2.has(numberOf(issue)) || linkedOf(issue) != null && bare2.has(linkedOf(issue))); ++ if (bareHits.length === 1) return bareHits[0]; ++ const byNumber = bareHits.filter((issue) => bare2.has(numberOf(issue))); ++ return byNumber.length === 1 ? byNumber[0] : null; ++} + +// src/client/delivery-view.js +var import_react16 = require("react"); @@ -10379,41 +10492,11 @@ diff --git a/client.js b/client.js + "not-started": t("Not started"), + "in-progress": t("In progress"), + "done": t("Done") -+ }; + }; +- visit(root); +- return calls; + return (0, import_react16.createElement)( - "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); ++ "div", + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( + "section", @@ -10440,7 +10523,15 @@ diff --git a/client.js b/client.js + })) + )) + ); -+} + } +-function useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); +function duration(ms) { + if (!Number.isFinite(ms) || ms < 0) return "—"; + const hours = ms / 36e5; @@ -10533,7 +10624,27 @@ diff --git a/client.js b/client.js + }, + open: snapshot?.open ?? {} + }; -+} + } +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +- const self = roster.byId[sessionId]; +- switch (tool) { +- case "message_bot": { +- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "stop_bot": { +- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "create_bot": { +- const created = findBot(arg("name")); +- if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); +function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { + let stopped = false; + let timer = null; @@ -10559,18 +10670,7 @@ diff --git a/client.js b/client.js + state = "failed"; + last = null; + onView({ state: "failed", error: { message: "The host did not answer", kind: "network", endpoint: null, status: null, at: null } }); - } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); ++ } + schedule(); + } catch (error) { + if (stopped) return; @@ -10578,7 +10678,10 @@ diff --git a/client.js b/client.js + last = null; + onView({ state: "failed", error: { message: String(error?.message ?? error), kind: "network", endpoint: null, status: null, at: null } }); + schedule(); -+ } + } +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; + }; + return { + start: () => { @@ -10594,16 +10697,33 @@ diff --git a/client.js b/client.js + if (timer !== null) { + clearTimer(timer); + timer = null; - } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); ++ } + void run(true); + }, + stop: () => { + stopped = true; + if (timer !== null) clearTimer(timer); + timer = null; -+ } + } +- case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); +- case "create_group": +- case "update_group": +- case "delete_group": +- case "post_to_group": { +- const verbs = { +- create_group: ["Created group", "Couldn't create the group"], +- update_group: ["Updated group", "Couldn't update the group"], +- delete_group: ["Deleted group", "Couldn't delete the group"], +- post_to_group: ["Posted in", "Couldn't post in the group"] +- }[tool]; +- if (!done) return null; +- const result = contentText(call2.content); +- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); +- const name = named[2]; +- const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); + }; +} +function repoSourceText(source) { @@ -10623,20 +10743,7 @@ diff --git a/client.js b/client.js + onView: (next) => { + current = next; + setView(next); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( -- "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); -- }) -- ); ++ } + }); + setRetry(() => () => poller.retry(current)); + poller.start(); @@ -10656,18 +10763,7 @@ diff --git a/client.js b/client.js + } + if (report.error) lines.push(String(report.error)); + return lines.join("\n"); - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); ++} +function Diagnose({ onProbe }) { + const [report, setReport] = (0, import_react16.useState)(null); + const [busy, setBusy] = (0, import_react16.useState)(false); @@ -10677,9 +10773,7 @@ diff --git a/client.js b/client.js + Promise.resolve(onProbe?.()).then((next) => { + setReport(next ?? { rows: [], error: "no answer" }); + }).catch((error) => setReport({ rows: [], error: String(error?.message ?? error) })).finally(() => setBusy(false)); - }; -- visit(root); -- return calls; ++ }; + return (0, import_react16.createElement)( + "span", + { className: "bt-del-diag" }, @@ -10701,15 +10795,7 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setReport(null) }, t("Close")) + ) : null + ); - } --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); ++} +function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { + const payload = view ?? snapshot; + if (!payload) return null; @@ -10765,27 +10851,7 @@ diff --git a/client.js b/client.js + onProbe ? (0, import_react16.createElement)(Diagnose, { onProbe }) : null + ) + ); - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); ++} +var BOARD_FILTERS = ["all", "review", "done-today"]; +function BoardFilters({ filter, onFilter }) { + return (0, import_react16.createElement)( @@ -10811,20 +10877,6 @@ diff --git a/client.js b/client.js + return typeof fallback === "string" ? fallback.trim() : ""; +}; +var recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}`; -+function namedRepos(task) { -+ if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`]; -+ const text = `${task?.title ?? ""} -+${task?.note ?? ""}`; -+ const repos = []; -+ for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`); -+ for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`); -+ return repos; -+} -+var repoOk = (task, repo) => { -+ const named = namedRepos(task); -+ if (named.length === 0 || repo === "") return true; -+ return named.includes(repo); -+}; +function hangRecords(columns, tasks, repo) { + const issues = []; + for (const cards of Object.values(columns ?? {})) { @@ -10832,57 +10884,16 @@ diff --git a/client.js b/client.js + if (card?.kind !== "issue" || card.number == null) continue; + issues.push({ card, repo: cardRepoOf(card, repo) }); } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; +- case "ask_user": { +- const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); + } + const nested = /* @__PURE__ */ new Map(); + const taken = /* @__PURE__ */ new Set(); + const open = (tasks ?? []).filter((task) => task && task.kind !== "group" && !TERMINAL_TASK.has(task.status) && task.id != null); + for (const task of open) { -+ let best = null; -+ let bestScore = -1; -+ const hits = []; -+ for (const issue of issues) { -+ if (!repoOk(task, issue.repo)) continue; -+ const namesIssue2 = cardNamesIssue(task, issue.card.number); -+ const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked); -+ if (!namesIssue2 && !namesPull) continue; -+ hits.push(issue); - } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ const bare2 = namedRepos(task).length === 0; -+ const pool = bare2 && hits.length > 1 ? [] : hits; -+ for (const issue of pool) { -+ const target = Number(task.target?.number); -+ const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0; -+ if (score > bestScore) { -+ best = issue; -+ bestScore = score; -+ } - } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); ++ const best = issueCardForTask(task, issues); + if (!best) continue; + const key = recordKey(best.repo, best.card.number); + const list = nested.get(key) ?? []; diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 025dc4949..db39091b7 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -14,7 +14,7 @@ import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard, githubCardKey } from '../shared/board-card.js' -import { cardNamesIssue } from '../shared/issue-ref.js' +import { issueCardForTask } from '../shared/issue-ref.js' import { BoardCard, DELIVERY_SKIN } from './board-card.js' import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' @@ -450,28 +450,10 @@ const cardRepoOf = (card, fallback) => { const recordKey = (repo, number) => githubCardKey(repo, number) ?? `#${Number(number)}` -// The repository a record already names, read off the same text `cardNamesIssue` matches. -// A bare `#12` names no repository. `target` is the host's normalised pointer and wins over a -// mention in the title, which is how a dispatch and the board stay on one card. -function namedRepos(task) { - if (task?.target?.owner && task?.target?.repo) return [`${task.target.owner}/${task.target.repo}`] - const text = `${task?.title ?? ''}\n${task?.note ?? ''}` - const repos = [] - for (const match of text.matchAll(/(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#\d+/g)) repos.push(`${match[1]}/${match[2]}`) - for (const match of text.matchAll(/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/\d+/gi)) repos.push(`${match[1]}/${match[2]}`) - return repos -} - -const repoOk = (task, repo) => { - const named = namedRepos(task) - if (named.length === 0 || repo === '') return true - return named.includes(repo) -} - -// A record hangs under the issue `cardNamesIssue` would hand a dispatch (#1254, #1331). The -// issue's `linked` pull request is the closing-keyword link the snapshot already computed (#1189), -// so a record that names that pull request hangs under the issue too. One open record per issue; -// an older open record that names the same issue is not drawn again. +// A record hangs under the issue `issueCardForTask` names (#1254, #1331, #1360). That is +// `cardNamesIssue` on the issue number or on the closing-keyword pull request the snapshot +// already stored as `linked` (#1189), and a repository only when the same text writes one. +// One open record per issue; an older open record that names the same issue is not drawn again. export function hangRecords(columns, tasks, repo) { const issues = [] for (const cards of Object.values(columns ?? {})) { @@ -484,24 +466,7 @@ export function hangRecords(columns, tasks, repo) { const taken = new Set() const open = (tasks ?? []).filter(task => task && task.kind !== 'group' && !TERMINAL_TASK.has(task.status) && task.id != null) for (const task of open) { - let best = null - let bestScore = -1 - const hits = [] - for (const issue of issues) { - if (!repoOk(task, issue.repo)) continue - const namesIssue = cardNamesIssue(task, issue.card.number) - const namesPull = issue.card.linked != null && cardNamesIssue(task, issue.card.linked) - if (!namesIssue && !namesPull) continue - hits.push(issue) - } - // A bare `#12` on a board where two repositories both have that issue names neither. - const bare = namedRepos(task).length === 0 - const pool = bare && hits.length > 1 ? [] : hits - for (const issue of pool) { - const target = Number(task.target?.number) - const score = target === Number(issue.card.number) ? 2 : target === Number(issue.card.linked) ? 1 : 0 - if (score > bestScore) { best = issue; bestScore = score } - } + const best = issueCardForTask(task, issues) if (!best) continue const key = recordKey(best.repo, best.card.number) const list = nested.get(key) ?? [] diff --git a/patches/ds-bot/files/src/shared/issue-ref.js b/patches/ds-bot/files/src/shared/issue-ref.js index ee1cbd167..48316b46b 100644 --- a/patches/ds-bot/files/src/shared/issue-ref.js +++ b/patches/ds-bot/files/src/shared/issue-ref.js @@ -29,3 +29,63 @@ export function cardNamesIssue(task, wanted) { } export const issueRefPattern = () => new RegExp(ISSUE_REF.source, 'gi') + +// The same mentions as ISSUE_REF, with the repository when the text writes one beside the number +// (`owner/repo#12`, or a github.com issues/pull URL). A bare `#12` carries no repository. The +// number is the same one `issueRefs` returns; the repository is not a second decision. +const MENTION = /(?:github\.com\/)?([\w.-]+)\/([\w.-]+)#(\d{1,7})(?![0-9])|github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d{1,7})(?![0-9])|#(\d{1,7})(?![0-9])/gi + +const mentionOf = (match) => { + if (match[3]) return { repo: `${match[1]}/${match[2]}`, number: match[3] } + if (match[6]) return { repo: `${match[4]}/${match[5]}`, number: match[6] } + if (match[7]) return { repo: null, number: match[7] } + return null +} + +// Mentions in the text `cardNamesIssue` consults: title and `target.href`, and the note only +// when those name no issue. A note cannot attach a repository to a number the title already named. +export function cardMentions(task) { + const own = `${task?.title ?? ''} ${task?.target?.href ?? ''}` + const text = issueRefs(own).length > 0 ? own : `${own} ${task?.note ?? ''}` + const mentions = [] + const seen = new Set() + for (const match of text.matchAll(new RegExp(MENTION.source, 'gi'))) { + const mention = mentionOf(match) + if (!mention) continue + const key = `${mention.repo ?? ''}#${mention.number}` + if (seen.has(key)) continue + seen.add(key) + mentions.push(mention) + } + return mentions +} + +const asNumber = (value) => (value == null || value === '' ? null : String(value)) + +// The one issue card this task hangs under, or null when `cardNamesIssue` names none or names +// more than one. `issues` are `{ repo, number, linked }` or `{ repo, card: { number, linked } }`. +// A repository written next to the number selects that card; a bare number selects the only +// card with that number (or the only card whose linked pull request has it). +export function issueCardForTask(task, issues) { + const mentions = cardMentions(task) + if (mentions.length === 0) return null + const list = (issues ?? []).filter((issue) => asNumber(issue?.card?.number ?? issue?.number) != null) + const numberOf = (issue) => asNumber(issue?.card?.number ?? issue?.number) + const linkedOf = (issue) => asNumber(issue?.card?.linked ?? issue?.linked) + const repoOf = (issue) => (typeof issue?.repo === 'string' ? issue.repo : '') + const qualified = list.filter((issue) => mentions.some((mention) => + mention.repo != null && mention.repo === repoOf(issue) + && (mention.number === numberOf(issue) || mention.number === linkedOf(issue)))) + if (qualified.length === 1) return qualified[0] + if (qualified.length > 1) { + const byNumber = qualified.filter((issue) => mentions.some((mention) => + mention.repo === repoOf(issue) && mention.number === numberOf(issue))) + return byNumber.length === 1 ? byNumber[0] : null + } + const bare = new Set(mentions.filter((mention) => mention.repo == null).map((mention) => mention.number)) + if (bare.size === 0) return null + const bareHits = list.filter((issue) => bare.has(numberOf(issue)) || (linkedOf(issue) != null && bare.has(linkedOf(issue)))) + if (bareHits.length === 1) return bareHits[0] + const byNumber = bareHits.filter((issue) => bare.has(numberOf(issue))) + return byNumber.length === 1 ? byNumber[0] : null +} diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index ea2dbb171..6473b3e7a 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -1012,3 +1012,46 @@ test('#1360: with no issue on the board, an orphan and a linked task both stay t assert.equal(walk(missing).some(node => node.props?.className === 'bt-board-subtask'), false) assert.equal(walk(missing).filter(node => node.props?.['data-task'] === 'task-linked').length, 1) }) + +// #1360 review: the note is not a second matcher. `cardNamesIssue` reads it only when the title +// and target name no issue, so a repository written only in the note cannot move the card. +test('#1360: a title that names #12 nests under that issue even when the note names another repository', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: 'acme/board', asOf: '2026-10-27T06:00:00Z', + columns: { + 'not-started': [], + 'in-progress': [issueCard('acme/board', 12)], + done: [], + }, + }, + tasks: [{ + id: 'task-title', + title: '#12 implement', + note: 'blocked on ThinkFlowLab/FlyBot#9', + status: 'working', + }], + }) + const progress = columnOf(board, 'in-progress') + assert.equal(topLevel(progress).length, 1) + const sub = walk(progress).find(node => node.props?.className === 'bt-board-subtask') + assert.equal(sub?.props['data-parent'], 'acme/board#12') + assert.equal(walk(sub).find(node => node.props?.['data-task'])?.props['data-task'], 'task-title') +}) + +test('#1360: a bare #12 stays top-level when two issues share it, even if the note names one repository', () => { + const board = DeliveryBoard({ + view: { + state: 'fresh', repo: null, asOf: '2026-10-27T06:00:00Z', + repos: [ + { repo: 'acme/one', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/one', 12)], done: [] } }, + { repo: 'acme/two', state: 'fresh', columns: { 'not-started': [], 'in-progress': [issueCard('acme/two', 12)], done: [] } }, + ], + }, + tasks: [{ id: 'task-bare-note', title: '#12', note: 'blocked on acme/two#99', status: 'working' }], + }) + const progress = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'] === 'in-progress' && node.props['data-workflow'] === undefined) + assert.equal(walk(board).some(node => node.props?.className === 'bt-board-subtask'), false) + const shown = progress.flatMap(column => walk(column).filter(node => node.props?.['data-task'] === 'task-bare-note')) + assert.equal(shown.length, 1, 'the bare number stays one top-level card') +})