From cd1030941ad277f221aa1f72614f2d38dc2f5848 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 15:10:51 +0800 Subject: [PATCH] Project board: read the queue, leases and blocked cards Replayed onto main (84de6663339ad8773c3f85fb200f0bdbfc6ca724) to clear the merge conflict; the change itself is unchanged. Written-by: Replay --- changelog.d/1768-project-board-queue.added.md | 1 + core/client.js | 92 +- core/src/client/i18n.js | 3 + core/src/client/project-board.js | 97 +- core/src/host/project-board.js | 90 +- core/src/host/store.js | 12 +- core/test/project-board-view.test.js | 52 +- core/test/project-board.test.js | 64 + docs/subsystems/core-client.md | 8 + docs/subsystems/core-host.md | 14 + patches/ds-bot/0001-core-fixes.patch | 82 +- patches/ds-bot/0002-flybot-api-host.patch | 38 +- patches/ds-bot/0008-client-bundle.patch | 1211 +++++++++-------- .../ds-bot/files/src/client/project-board.js | 97 +- .../ds-bot/files/src/host/project-board.js | 90 +- .../files/test/project-board-view.test.js | 52 +- .../ds-bot/files/test/project-board.test.js | 64 + 17 files changed, 1420 insertions(+), 647 deletions(-) create mode 100644 changelog.d/1768-project-board-queue.added.md diff --git a/changelog.d/1768-project-board-queue.added.md b/changelog.d/1768-project-board-queue.added.md new file mode 100644 index 000000000..64da56324 --- /dev/null +++ b/changelog.d/1768-project-board-queue.added.md @@ -0,0 +1 @@ +The Project board now shows the work beside its columns: cards waiting for a role (oldest wait first, with the role and how long), the member holding each card and whether its lease is live or stalled, and blocked cards with their one-line reason, plus waiting / in flight / blocked / done counts in the header. An unreadable queue reads "unknown", never 0 ([#1768](https://github.com/ThinkFlowLab/FlyBot/issues/1768)). diff --git a/core/client.js b/core/client.js index f5019a595..7ee8115ab 100644 --- a/core/client.js +++ b/core/client.js @@ -795,6 +795,9 @@ var ZH = { Low: "较低", Claimed: "已认领", Blocked: "受阻", + Waiting: "等待", + "In flight": "进行中", + unknown: "未知", Start: "开始", Complete: "完成", "Start task": "开始任务", @@ -19873,6 +19876,14 @@ var PROJECT_BOARD_CSS = ` .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} +.bt-project-counts b{font-weight:600} +.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} +.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} +.bt-project-state{font-size:11px;margin-left:auto} +.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} +.bt-project-reason{font-size:11px;opacity:.7} `; var PROJECT_SOURCES = ["bot", "group", "workflow"]; var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; @@ -19941,7 +19952,7 @@ function projectBoardRecord({ decisions, meetings } = {}) { } }; } -function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { +function projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues } = {}) { const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); const groups = PROJECT_SOURCES.map((source) => ({ @@ -19963,13 +19974,31 @@ function projectBoardView({ project, rows, canManage, tasks, decisions, meetings }; }) })).filter((group) => group.rows.length > 0); + const countOf = (value) => value === void 0 || value === null ? null : value; + const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { + const id = text(item.id); + const source = text(list.find((row) => text(row.id) === id)?.source); + const task = byId.get(id); + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; + }); + const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); + const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null })); + const queueGroups = [ + { group: "waiting", label: "Waiting", items: waiting }, + { group: "inflight", label: "In flight", items: inflight }, + { group: "blocked", label: "Blocked", items: blocked } + ].filter((entry) => entry.items.length > 0); + const rawCounts = queues?.counts ?? null; return { projectId: text(project?.id) || null, title: text(project?.name) || "Project", canManage: canManage === true, groups, + queues: queueGroups, + counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, count: list.length, - empty: list.length === 0, + empty: list.length === 0 && queueGroups.length === 0, emptyText: "No cards yet.", record: projectBoardRecord({ decisions, meetings }) }; @@ -20012,6 +20041,47 @@ function ProjectBoardRows({ view, onOpen }) { }) )); } +function waitedLabel(ms) { + if (!Number.isFinite(ms) || ms < 0) return null; + const minutes = Math.floor(ms / 6e4); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map((entry) => (0, import_react46.createElement)( + "section", + { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, + (0, import_react46.createElement)("h4", null, t(entry.label)), + entry.items.map((row) => { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); + const note = entry.group === "waiting" ? (0, import_react46.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react46.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react46.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react46.createElement)("span", { className: "bt-project-reason" }, row.reason); + return (0, import_react46.createElement)( + "div", + { + key: row.key, + className: "bt-project-row", + "data-task-id": row.id, + "data-state": row.state ?? void 0, + role: open === void 0 ? void 0 : "button", + tabIndex: open === void 0 ? void 0 : 0, + onClick: open, + onKeyDown: open === void 0 ? void 0 : (event) => { + if (event.key === "Enter" || event.key === " ") open(); + } + }, + (0, import_react46.createElement)( + "span", + { className: "bt-project-title-wrap" }, + (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) + ), + note + ); + }) + )); +} +var countText = (value) => value === null ? t("unknown") : String(value); function ProjectBoardRecord({ record }) { const { decisions, meetings } = record; const decisionRows = decisions.files.map((file) => (0, import_react46.createElement)( @@ -20049,15 +20119,25 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows ); } -function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }); +function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }); return (0, import_react46.createElement)( "div", { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, (0, import_react46.createElement)("h3", null, view.title), + // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. + view.counts === null ? null : (0, import_react46.createElement)( + "div", + { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, + (0, import_react46.createElement)("span", null, [t("Waiting"), ": ", (0, import_react46.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), + (0, import_react46.createElement)("span", null, [t("In flight"), ": ", (0, import_react46.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), + (0, import_react46.createElement)("span", null, [t("Blocked"), ": ", (0, import_react46.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), + (0, import_react46.createElement)("span", null, [t("Done"), ": ", (0, import_react46.createElement)("b", { key: "d" }, countText(view.counts.done))]) + ), // Management affordances are drawn only for the owner; the refusal itself is the host's. view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }), + (0, import_react46.createElement)(ProjectQueueGroups, { view, onOpen }), (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) ); } @@ -20099,7 +20179,8 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { read("tasks", {}) ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; - if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues }); } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); } @@ -20141,6 +20222,7 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { tasks: state.tasks, decisions: state.decisions, meetings: state.meetings, + queues: state.queues, // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 577751c27..7c5144071 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -739,6 +739,9 @@ const ZH = { Low: '较低', Claimed: '已认领', Blocked: '受阻', + Waiting: '等待', + 'In flight': '进行中', + unknown: '未知', Start: '开始', Complete: '完成', 'Start task': '开始任务', diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index e371d15f9..7f31a6f6a 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -34,6 +34,14 @@ export const PROJECT_BOARD_CSS = ` .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} +.bt-project-counts b{font-weight:600} +.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} +.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} +.bt-project-state{font-size:11px;margin-left:auto} +.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} +.bt-project-reason{font-size:11px;opacity:.7} ` // Sources in the order the board reads them; a source with no rows is left out so three empty @@ -124,7 +132,9 @@ export function projectBoardRecord({ decisions, meetings } = {}) { // `project-read` and only decides what the caller may *ask* for — the host refuses regardless. // `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. // `decisions` and `meetings` are the host's read of the Project's record (#1767), passed through. -export function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { +// `queues` is the other half of that same read (#1768): the waiting, in-flight and blocked cards +// and their counts, as the host answered them. +export function projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues } = {}) { const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] const byId = new Map((Array.isArray(tasks) ? tasks : []) .filter(task => task !== null && typeof task === 'object' && text(task.id) !== '') @@ -152,13 +162,36 @@ export function projectBoardView({ project, rows, canManage, tasks, decisions, m }), })) .filter(group => group.rows.length > 0) + // The three groups beside the columns (#1768). They are the host's own read of the queue, the + // leases and the blocked cards; nothing is recomputed here, and a count the host could not read + // stays `null` so the header says "unknown" instead of a zero nobody measured. + const countOf = (value) => (value === undefined || value === null ? null : value) + const queueItems = (items, annotate) => (Array.isArray(items) ? items : []) + .filter(item => item !== null && typeof item === 'object' && text(item.id) !== '') + .map((item) => { + const id = text(item.id) + const source = text(list.find(row => text(row.id) === id)?.source) + const task = byId.get(id) + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) } + }) + const waiting = queueItems(queues?.waiting, item => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) + const inflight = queueItems(queues?.inflight, item => ({ holder: text(item.holder) || null, state: item.stalled === true ? 'stalled' : 'running' })) + const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null })) + const queueGroups = [ + { group: 'waiting', label: 'Waiting', items: waiting }, + { group: 'inflight', label: 'In flight', items: inflight }, + { group: 'blocked', label: 'Blocked', items: blocked }, + ].filter(entry => entry.items.length > 0) + const rawCounts = queues?.counts ?? null return { projectId: text(project?.id) || null, title: text(project?.name) || 'Project', canManage: canManage === true, groups, + queues: queueGroups, + counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, count: list.length, - empty: list.length === 0, + empty: list.length === 0 && queueGroups.length === 0, emptyText: 'No cards yet.', record: projectBoardRecord({ decisions, meetings }), } @@ -193,6 +226,49 @@ function ProjectBoardRows({ view, onOpen }) { }))) } +// How long a card has waited, in the unit a reader wants: minutes under an hour, hours under a +// day. A card with no `createdAt` says nothing rather than "0m". +export function waitedLabel(ms) { + if (!Number.isFinite(ms) || ms < 0) return null + const minutes = Math.floor(ms / 60000) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + return `${Math.floor(hours / 24)}d` +} + +// The three groups beside the columns (#1768): a role queue with its wait, the card a claim holds +// with its holder and lease state, and a blocked card with the reason it already stores. +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map(entry => h('section', { key: `queue:${entry.group}`, className: 'bt-project-group', 'data-queue': entry.group }, + h('h4', null, t(entry.label)), + entry.items.map(row => { + const open = row.board === null ? undefined : () => onOpen?.(row.board) + // The annotation is the group's own: a role and how long it waited, the holder and whether + // the lease is live, or the reason the card is blocked. Never recomputed from the card here. + const note = entry.group === 'waiting' + ? h('span', { className: 'bt-project-wait' }, [row.role === null ? null : h('span', { key: 'role', className: 'bt-project-role' }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) + : entry.group === 'inflight' + ? h('span', { className: 'bt-project-state' }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) + : h('span', { className: 'bt-project-reason' }, row.reason) + return h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-state': row.state ?? undefined, + role: open === undefined ? undefined : 'button', + tabIndex: open === undefined ? undefined : 0, + onClick: open, + onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, + }, + h('span', { className: 'bt-project-title-wrap' }, + h('span', { className: 'bt-project-title' }, row.title ?? `#${row.id}`)), + note) + }))) +} + +const countText = value => (value === null ? t('unknown') : String(value)) + // The record beside the cards (#1767): decisions first, then the meetings that reached one, and // a meeting still open or blocked listed apart — never as a decision. Every row is text the host // already answered; the only thing this reads again is the hash it was handed. @@ -224,13 +300,20 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows) } -export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }) +export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), + // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. + view.counts === null ? null : h('div', { className: 'bt-project-counts', 'data-counts': 'waiting,inflight,blocked,done' }, + h('span', null, [t('Waiting'), ': ', h('b', { key: 'w' }, countText(view.counts.waiting))]), + h('span', null, [t('In flight'), ': ', h('b', { key: 'f' }, countText(view.counts.inflight))]), + h('span', null, [t('Blocked'), ': ', h('b', { key: 'b' }, countText(view.counts.blocked))]), + h('span', null, [t('Done'), ': ', h('b', { key: 'd' }, countText(view.counts.done))])), // Management affordances are drawn only for the owner; the refusal itself is the host's. view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, h(ProjectBoardRows, { view, onOpen }), + h(ProjectQueueGroups, { view, onOpen }), h(ProjectBoardRecord, { record: view.record })) } @@ -273,7 +356,9 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { read('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) + // The groups are the same read as the rows, so the header cannot disagree with the cards. + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null } + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -297,7 +382,7 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { // that hash and lands on the Bot or group the task belongs to. : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, - decisions: state.decisions, meetings: state.meetings, + decisions: state.decisions, meetings: state.meetings, queues: state.queues, // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index ee8c091d3..08f9a5565 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -7,6 +7,10 @@ // folder goes through `listProjectState`, which already hashes every file, and a decision file // is listed with a name, a date and one title line, never its body. // +// #1768 adds the queue beside those: the waiting cards of a role queue, the cards a claim holds +// (with its lease live or stalled) and the blocked cards, each with the line it already stores. +// They are derived from the same `state.tasks` pass as the rows, never a second store. +// // Read-only by construction: it reads `state.tasks`, `state.meetings` and the Project bindings, // asks each registered provider for the workflow refs of tasks that still exist, and returns the // sections. It writes nothing, starts nothing and is not called unless someone asks for the board. @@ -17,6 +21,44 @@ import { DECISIONS_DIR, listProjectState } from './project-state.js' const text = value => (typeof value === 'string' ? value.trim() : '') +// The queue, the leases and the blocked cards are host truth the board never copied (#1768): +// a member's role queue is `queueRole` on an open card with no `claimedBy` (`tasks.js`), a lease is +// the one `tasks.js` writes, and a blocked card already stores the question it waits on. This read +// derives those three groups from those fields, oldest wait first, and writes nothing. +const ONE_LINE = 200 +const oneLine = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, ONE_LINE) : '') +const stampOf = value => (Number.isFinite(value) ? value : null) + +// Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's +// own `createdAt`, so the board shows a duration a reader can check, not a guess. +export function waitingOf(task, now) { + if (task === null || typeof task !== 'object') return null + if (task.status !== 'open') return null + const role = oneLine(task.queueRole) + if (role === '' || text(task.claimedBy) !== '') return null + const since = stampOf(task.createdAt) + return { id: String(task.id), role, since, waitedMs: since === null ? null : Math.max(0, now - since) } +} + +// In flight: the card a member holds. A live lease reads running; an expired one reads stalled — +// the card record is untouched either way, recovery stays `tasks.js`'s own path (#1720). +export function inflightOf(task) { + if (task === null || typeof task !== 'object') return null + if (task.status !== 'claimed' && task.status !== 'working') return null + // The holder is the claim, not the assignee label: a card nobody claimed is waiting work, and + // only the queue's own claim (or the lease that claim wrote) says a member holds it. + const holder = oneLine(task.claimedBy) || oneLine(task.lease?.holder) + if (holder === '') return null + return { id: String(task.id), holder } +} + +// Blocked: the reason is the card's own line, never a second copy. +export function blockedOf(task) { + if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null + const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || '' + return { id: String(task.id), reason } +} + // A decision is read for its title line only: the head of the file is enough for a heading, and // the body stays where it is. The bound also keeps a megabyte file from being read to answer one // line; the hash the reader needs comes from `listProjectState`, not from this read. @@ -176,15 +218,21 @@ export function install(rt) { const id = text(projectId) const empty = { rows: [], + waiting: [], + inflight: [], + blocked: [], + counts: { waiting: 0, inflight: 0, blocked: 0, done: 0 }, decisions: { dir: null, files: [], reason: 'no Project: nothing to read' }, meetings: { decided: [], pending: [], reason: 'no Project: nothing to read' }, } if (id === '') return empty const project = rt.readProject?.(id) if (!project) return empty - const tasks = rt.state?.tasks ?? {} + // A store that cannot be read is not an empty store (#1768): `null` keeps the two apart. + const tasks = rt.state?.tasks + const readable = tasks !== null && typeof tasks === 'object' const refs = new Map() - for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks, taskId) }) ?? []) { + for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks ?? {}, taskId) }) ?? []) { refs.set(ref.taskId, ref) } // #1723: a row is painted only when the caller could open that card through `tasks`. The @@ -192,7 +240,8 @@ export function install(rt) { // nothing, because an unreadable rule is not a reason to widen a board. const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : () => false const rows = [] - for (const task of Object.values(tasks)) { + const mine = [] + for (const task of readable ? Object.values(tasks) : []) { if (task === null || typeof task !== 'object') continue if (!visible(task, actor)) continue const board = boardOf(task) @@ -209,6 +258,7 @@ export function install(rt) { ? ownProject.id === id ? board.kind : null : runProject?.id === id ? 'workflow' : null if (source === null) continue + mine.push(task) const fromRun = ref !== undefined && (runProject === null || runProject.id === id) const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' rows.push({ @@ -232,8 +282,40 @@ export function install(rt) { ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } + const done = rows.filter(row => row.column === 'done').length + // An unreadable store answers unknown (#1768): `0` would claim the queue is empty, and the + // board must not turn "cannot read" into "nothing waiting". + const queues = !readable + ? { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } + : buildQueues(mine, done) const decisions = await decisionsOf(project) - return { rows, decisions, meetings: meetingsOf(project, decisions) } + return { rows, ...queues, decisions, meetings: meetingsOf(project, decisions) } + } + const buildQueues = (mine, done) => { + const now = typeof rt.now === 'function' ? rt.now() : Date.now() + const waiting = [] + const inflight = [] + const blocked = [] + for (const task of mine) { + const wait = waitingOf(task, now) + if (wait !== null) waiting.push(wait) + const flight = inflightOf(task) + if (flight !== null) { + const lease = task.lease !== null && typeof task.lease === 'object' ? task.lease : null + const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now + inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live }) + } + const block = blockedOf(task) + if (block !== null) blocked.push(block) + } + // Oldest wait first: the card that has waited longest is the one a person wants next. + waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id)) + return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } } } rt.projectBoard = projectBoard + // The three groups are their own read over the same pass, for a caller that wants only the queue. + rt.projectBoardQueues = async (projectId, actor) => { + const board = await projectBoard(projectId, actor) + return { waiting: board.waiting, inflight: board.inflight, blocked: board.blocked, counts: board.counts } + } } diff --git a/core/src/host/store.js b/core/src/host/store.js index 517ef5282..6127b1d64 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -644,9 +644,9 @@ export function install(rt) { return { project: readProject(project.id), ...(result ?? {}) } } case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) } - // The read-only board (#1696, #1767): Project id in, its cards and the record beside them - // out. The aggregate lives in project-board.js; it reads and returns, and writes nothing. - // A host that has not installed it answers empty sections, never a missing key. + // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in + // project-board.js; it reads and returns, and writes nothing. The actor goes with the id + // so the board applies the same visibility rule `tasks` does (#1723). case 'project-board': { const board = (await rt.projectBoard?.(projectText(body.id, 200), actor)) ?? [] const rows = Array.isArray(board) ? board : (board.rows ?? []) @@ -655,6 +655,12 @@ export function install(rt) { tasks: rows, decisions: sections.decisions ?? { dir: null, files: [], reason: 'the board reads no decisions' }, meetings: sections.meetings ?? { decided: [], pending: [], reason: 'the board reads no meetings' }, + // The queue/lease/blocked groups (#1768) ride along with the sections: one read of the + // same Project, so the header counts and the cards cannot disagree. + waiting: sections.waiting ?? [], + inflight: sections.inflight ?? [], + blocked: sections.blocked ?? [], + counts: sections.counts ?? { waiting: null, inflight: null, blocked: null, done: null }, } } default: throw projectRefusal(`Unknown endpoint ${endpoint}`, 'bot/not-found') diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index 3ee6fd7b8..c5b38709d 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -38,7 +38,7 @@ runInNewContext(bundle.outputFiles[0].text, { throw new Error(`no platform module ${name}`) }, }) -const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView } = module.exports +const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView, waitedLabel } = module.exports const render = (component, props) => { index = 0; return component(props) } const flush = async () => { const queued = effects @@ -200,3 +200,53 @@ test('#1767: an empty record names the folder it read and the missing decision f assert.equal(bare.record.decisions.count, 0) assert.match(bare.record.decisions.emptyText, /no working folder/) }) + +// #1768: the three groups beside the columns and the header counts. The host's read is passed in +// as it came — including a `null` count, which must survive as "unknown" and never become 0. +const queues = { + waiting: [ + { id: 'task-w1', role: 'Dev', since: 100, waitedMs: 3 * 3600 * 1000 }, + { id: 'task-w2', role: 'Dev', since: 200, waitedMs: 5 * 60 * 1000 }, + ], + inflight: [{ id: 'task-b1', holder: 'Dev4', lease: 'expired', stalled: true }], + blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?' }], + counts: { waiting: 2, inflight: 1, blocked: 1, done: 1 }, +} + +test('#1768: the view carries the three groups and the header counts', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, tasks: [], queues })) + assert.deepEqual(view.counts, { waiting: 2, inflight: 1, blocked: 1, done: 1 }) + assert.deepEqual(view.queues.map(entry => entry.group), ['waiting', 'inflight', 'blocked']) + assert.deepEqual(view.queues[0].items.map(item => [item.id, item.role]), [['task-w1', 'Dev'], ['task-w2', 'Dev']]) + assert.deepEqual(view.queues[1].items.map(item => [item.id, item.holder, item.state]), [['task-b1', 'Dev4', 'stalled']]) + assert.deepEqual(view.queues[2].items.map(item => [item.id, item.reason]), [['task-g1', 'Which branch should this ship on?']]) + assert.equal(waitedLabel(3 * 3600 * 1000), '3h') + assert.equal(waitedLabel(5 * 60 * 1000), '5m') +}) + +test('#1768: an unreadable count stays unknown and is never rendered as 0', () => { + const view = plain(projectBoardView({ + project: { id: 'project-1', name: 'FlyBot' }, rows: [], tasks: [], + queues: { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } }, + })) + assert.deepEqual(view.counts, { waiting: null, inflight: null, blocked: null, done: null }) + const nodes = all(ProjectBoardPane({ project: view.projectId === null ? {} : { id: 'project-1', name: 'FlyBot' }, rows: [], canManage: false, tasks: [], queues: { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } })) + const text = nodes.map(textOf).join(' | ') + assert.ok(/unknown/.test(text), text) + assert.ok(!/Waiting: 0/.test(text), text) + // A board with nothing readable is its own state, not a silent zero. + assert.equal(view.empty, true) +}) + +test('#1768: the pane draws each group and a blocked card shows its reason', () => { + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues })) + const sections = nodes.filter(node => node && node.props && node.props['data-queue']) + assert.deepEqual(sections.map(node => node.props['data-queue']), ['waiting', 'inflight', 'blocked']) + const text = nodes.map(textOf).join(' | ') + assert.ok(text.includes('Dev4 · stalled'), text) + assert.ok(text.includes('Which branch should this ship on?'), text) + assert.ok(text.includes('3h'), text) + // The rows keep their task id, so the click-through to `#task=` is unchanged. + const ids = nodes.filter(node => node && node.props && node.props['data-task-id']).map(node => node.props['data-task-id']) + assert.ok(ids.includes('task-w1') && ids.includes('task-b1') && ids.includes('task-g1'), JSON.stringify(ids)) +}) \ No newline at end of file diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index b53d4356b..524cdc07e 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -361,3 +361,67 @@ test('#1767: a Project without a decisions folder answers an empty list with the rmSync(dir, { recursive: true, force: true }) } }) + +// #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of +// each shape on Project 1 and one on Project 2, so every group can be asked what it must not show. +const queueSeed = () => { + const team = seed() + team.rooms.r2 = { id: 'r2', name: 'Garden', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' } + team.projects['project-p2'] = { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' } + team.tasks['task-w1'] = { id: 'task-w1', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'First queued', createdAt: 10, updatedAt: 10 } + team.tasks['task-w2'] = { id: 'task-w2', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'Second queued', createdAt: 20, updatedAt: 20 } + team.tasks['task-hold'] = { id: 'task-hold', kind: 'group', status: 'claimed', queueRole: 'Dev', claimedBy: 'Dev1', origin: { roomId: 'r1' }, title: 'Claimed', lease: { holder: 'Dev1', takenAt: Date.now() - 1000, expiresAt: Date.now() + 600000 }, createdAt: 30, updatedAt: 30 } + team.tasks['task-blocked'] = { id: 'task-blocked', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Blocked card', inputRequest: { id: 'q1', question: 'Which branch should this ship on?' }, createdAt: 40, updatedAt: 40 } + team.tasks['task-far'] = { id: 'task-far', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r2' }, title: 'Other Project', createdAt: 50, updatedAt: 50 } + return team +} + +test('#1768: two queued cards and one claimed read as 2 waiting / 1 in flight, holder named', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const queues = await rt.projectBoardQueues('project-p1') + assert.equal(queues.counts.waiting, 2) + assert.equal(queues.counts.inflight, 1) + // Oldest wait first, with the role it is queued for. + assert.deepEqual(queues.waiting.map(item => item.id), ['task-w1', 'task-w2']) + assert.deepEqual(queues.waiting.map(item => item.role), ['Dev', 'Dev']) + assert.ok(Number.isFinite(queues.waiting[0].waitedMs) && queues.waiting[0].waitedMs >= queues.waiting[1].waitedMs) + // The holder is the member that claimed it, and a live lease reads running. + assert.deepEqual(queues.inflight.map(item => [item.id, item.holder, item.lease, item.stalled]), [['task-hold', 'Dev1', 'live', false]]) + } finally { await stop() } +}) + +test('#1768: an expired lease reads stalled and the card record does not move', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const before = JSON.stringify(rt.state.tasks['task-hold']) + rt.state.tasks['task-hold'].lease.expiresAt = Date.now() - 1000 + const moved = JSON.stringify(rt.state.tasks['task-hold']) + assert.notEqual(moved, before, 'the test moved the lease itself') + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.inflight.map(item => [item.id, item.stalled, item.lease]), [['task-hold', true, 'expired']]) + assert.equal(JSON.stringify(rt.state.tasks['task-hold']), moved, 'reading the board wrote nothing') + } finally { await stop() } +}) + +test('#1768: a blocked card carries its own reason and another Project is in none of the groups', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.blocked.map(item => [item.id, item.reason]), [['task-blocked', 'Which branch should this ship on?']]) + // task-far is queued for the same role but lives on Project 2, and the counts are this Project's. + const ids = [...queues.waiting, ...queues.inflight, ...queues.blocked].map(item => item.id) + assert.ok(!ids.includes('task-far'), JSON.stringify(ids)) + assert.deepEqual([queues.counts.waiting, queues.counts.inflight, queues.counts.blocked], [2, 1, 1]) + } finally { await stop() } +}) + +test('#1768: an unreadable queue answers unknown, never zero', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + rt.state.tasks = null + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.counts, { waiting: null, inflight: null, blocked: null, done: null }) + assert.deepEqual([queues.waiting, queues.inflight, queues.blocked], [[], [], []]) + } finally { await stop() } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a5b36d110..8d3b82862 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -297,6 +297,14 @@ no polling and no per-Bot read: `projects`, `project-read`, `project-board`, and are joined against. Tests: `core/test/project-board-view.test.js`, `core/test/project-board.test.js`, `core/test/project-board-entry.test.js`. +The pane draws the three host groups beside the columns (#1768) — `Waiting` with the role and how long +the card waited, `In flight` with the holder and `running`/`stalled`, `Blocked` with the card's own +reason — and the header counts waiting / in flight / blocked / done. The groups and the counts are the +`project-board` answer, not a second read: `waiting`, `inflight`, `blocked` and `counts` ride +along with `tasks` in the same response. A count the host answered as `null` renders as `unknown` and +never as `0`; a group with no items is not drawn, and a board with neither rows nor groups is still the +empty state. Rows keep their task id, so `#task=` still opens the card. + The board's three column names go through the same dictionary as the task board: `not-started`, `in-progress` and `done` are keys in `i18n.js`, so a zh UI reads 未开始 / 进行中 / 已完成 instead of the raw status. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0245eae90..62f5de81f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -593,6 +593,20 @@ another owner's quiet window or opt-out before that endpoint is wired; an omitte the caller's own. The gate returns early on a missing id, so `project-memory` keeps the not-found refusal it answered before rather than turning an unknown id into a 403. Tests: `core/test/project-owner-gate.test.js`. +Beside the columns the board reads three groups (#1768), from the same pass and never a +second store: `waiting` is an open card with a `queueRole` and no claim, oldest wait first, carrying +the role and the milliseconds since its own `createdAt`; `inflight` is the card a claim (or the lease +that claim wrote) holds, its holder named, a live lease reading `live` and an expired one `stalled` +without the card record changing; `blocked` is a blocked card with the one-line reason it already +stored (`inputRequest.question`, else `queueReason`, else its title). `projectBoard(projectId, actor)` +keeps answering the record it answered before — `rows`, `decisions`, `meetings` — and now carries the +groups and the header counts (`waiting`/`inflight`/`blocked`/`done`) from the same read; the +`project-board` endpoint answers them beside `tasks`, `decisions` and `meetings`, and +`rt.projectBoardQueues` is the same read for a caller that wants only the queue. A store the host +cannot read answers `counts` of `null`, never `0` (owner rule: an unreadable queue is unknown). +Nothing here writes, and the queue semantics in `tasks.js` are untouched. Tests: +`core/test/project-board.test.js`. + Reading the board (`project-board`) stays open to a non-owner: the same rows, read-only. Those rows are the ones the caller could open through `tasks` (#1723): `store.js` passes the authenticated actor into `projectBoard(projectId, actor)`, and the board drops every card diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 8e8054264..37bd38611 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -639,7 +639,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Shared 'Main Bot': '主 Bot', Working: '工作中', -@@ -12,6 +12,67 @@ const ZH = { +@@ -12,6 +66,67 @@ 'Unread activity': '有未读', Search: '搜索', 'New chat': '新对话', @@ -707,7 +707,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'New…': '新建', 'Animation': '动画', 'Bot animation': 'Bot 动画', -@@ -26,6 +26,21 @@ const ZH = { +@@ -26,6 +141,21 @@ Duplicate: '复制一份', Hidden: '已隐藏', Admin: '管理员', @@ -729,7 +729,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,73 @@ const ZH = { +@@ -41,22 +171,73 @@ ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -806,7 +806,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connectors: '连接器', 'Search connectors': '搜索连接器', 'No connectors': '暂无连接器', -@@ -93,6 +93,59 @@ const ZH = { +@@ -93,6 +274,59 @@ 'Connecting…': '正在连接…', 'Could not connect: {error}': '连接失败:{error}', 'Connected as {account}': '已连接:{account}', @@ -866,7 +866,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connected: '已连接', Disconnect: '断开', Connect: '连接', -@@ -105,17 +105,64 @@ const ZH = { +@@ -105,17 +339,64 @@ 'GitHub addresses must start with https://': 'GitHub 地址必须以 https:// 开头', 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', 'No such connector': '没有这个连接器', @@ -935,7 +935,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Header 'Message {name}': '给 {name} 发消息', -@@ -128,8 +128,6 @@ const ZH = { +@@ -128,8 +409,6 @@ New: '新', // New chat @@ -944,7 +944,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Add to group chat': '加入群聊', 'Message Bot': '给 Bot 发消息', 'To:': '发给:', -@@ -154,9 +154,24 @@ const ZH = { +@@ -154,9 +433,24 @@ Everyone: '所有人', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', @@ -969,7 +969,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Remove {name} from the group': '把 {name} 移出群聊', 'Remove from group': '移出群聊', 'Add member': '添加成员', -@@ -172,6 +172,16 @@ const ZH = { +@@ -172,6 +466,16 @@ 'Close details': '关闭详情', 'Copied conversation': '已复制对话', 'Share conversation': '分享对话', @@ -986,7 +986,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Copy conversation as Markdown': '把对话复制为 Markdown', 'This conversation is not part of your Bot team.': '这个对话不属于你的 Bot 团队。', 'Admin {name}': '管理员 {name}', -@@ -215,6 +215,25 @@ const ZH = { +@@ -215,6 +519,25 @@ 'This model is not available now. Pick another one.': '这个模型现在不可用,请换一个。', 'Every message of this Bot runs on it.': '这个 Bot 的每条消息都用它。', 'Applies from the next message.': '从下一条消息开始生效。', @@ -1012,7 +1012,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Profile: '资料', Label: '标签', 'Optional. Shown under the name.': '可选,显示在名字下方。', -@@ -238,6 +238,7 @@ const ZH = { +@@ -238,6 +561,7 @@ 'Kept above the other chats.': '排在其他对话上面。', 'Search still finds it.': '仍然可以搜索到。', Manage: '管理', @@ -1020,7 +1020,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Group chat settings 'Leave it empty to name the group after its members.': '留空就按成员命名。', -@@ -274,6 +274,8 @@ const ZH = { +@@ -274,6 +598,8 @@ 'Harness default': 'Harness 默认', Saving: '保存中', 'Manage models': '管理模型', @@ -1029,7 +1029,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Shapes Whale: '鲸鱼', Star: '星星', Moon: '月亮', Wave: '声波', Squircle: '圆角方', Bull: '牛角', Blossom: '花朵', -@@ -312,9 +312,10 @@ const ZH = { +@@ -312,9 +638,10 @@ 'No group chats yet.': '还没有群聊。', 'A group chat lets several Bots work on one thing with you.': '在群聊里,几个 Bot 和你一起做一件事。', 'Back to group chats': '返回群聊列表', @@ -1042,7 +1042,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Harness settings': 'Harness 设置', 'Back to Bots': '返回 Bot 列表', 'Close settings': '关闭设置', -@@ -324,7 +324,6 @@ const ZH = { +@@ -324,7 +651,6 @@ Pin: '置顶', Pinned: '已置顶', 'Mark as Unread': '标为未读', @@ -1050,7 +1050,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Group settings': '群设置', 'Rename group': '重命名群聊', 'Copy conversation ID': '复制对话 ID', -@@ -333,6 +333,8 @@ const ZH = { +@@ -333,6 +659,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. 'Add DSH Agent': '添加 DSH Agent', @@ -1059,7 +1059,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Remove DSH Agent': '移除 DSH Agent', 'New session': '新会话', Archive: '归档', -@@ -344,13 +344,13 @@ const ZH = { +@@ -344,13 +672,13 @@ '{n} d': '{n} 天', // Plugin updates @@ -1079,7 +1079,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'You have {version}.': '当前版本 {version}。', 'The update did not finish: {reason}': '更新没有完成:{reason}', "What's new": '更新说明', -@@ -360,9 +360,75 @@ const ZH = { +@@ -360,9 +688,78 @@ 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -1135,6 +1135,9 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + Low: '较低', + Claimed: '已认领', + Blocked: '受阻', ++ Waiting: '等待', ++ 'In flight': '进行中', ++ unknown: '未知', + Start: '开始', + Complete: '完成', + 'Start task': '开始任务', @@ -1156,7 +1159,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Command palette Messages: '消息', Commands: '命令', -@@ -390,10 +390,13 @@ const ZH = { +@@ -390,10 +787,13 @@ Reply: '回复', 'More message actions': '更多操作', More: '更多', @@ -1170,7 +1173,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', 'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试', -@@ -403,6 +403,9 @@ const ZH = { +@@ -403,6 +803,9 @@ 'Replies from': '群聊回复:', 'Reply from': '收到回复:', 'Message from': '收到消息:', @@ -1180,7 +1183,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -412,6 +412,8 @@ const ZH = { +@@ -412,6 +815,8 @@ 'Working out a plan': '理一下思路', 'Sorting out the details': '梳理细节', 'On it': '在做了', @@ -1189,7 +1192,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Busy with it': '正在处理', 'Moving ahead': '推进中', 'Taking care of it': '处理中', -@@ -439,7 +439,6 @@ const ZH = { +@@ -439,7 +844,6 @@ 'Looking back through the chat': '翻看聊天记录', '{name} looked up its own chat': '{name} 查看了自己的对话', '{name} looked up its own chat for “{text}”': '{name} 在自己的对话里查找「{text}」', @@ -1197,7 +1200,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "{name} didn't answer in time": '{name} 没有及时回应', '{name} paused for your approval': '{name} 停下来等你批准', '{name} started on it': '{name} 开始做了', -@@ -450,12 +450,31 @@ const ZH = { +@@ -450,12 +854,31 @@ 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', @@ -1230,7 +1233,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "Couldn't message {name}": '没能给 {name} 发消息', Messaged: '已发消息给', "Couldn't stop {name}": '没能叫停 {name}', -@@ -478,6 +478,7 @@ const ZH = { +@@ -478,6 +901,7 @@ Selected: '已选', Skip: '跳过', Submit: '提交', @@ -1238,7 +1241,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Choose all that apply': '可多选', 'Dismiss question': '关闭问题', Dismiss: '关闭', -@@ -485,7 +485,7 @@ const ZH = { +@@ -485,7 +909,7 @@ 'Your answer': '你的回答', // Secrets @@ -1247,7 +1250,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No Bot': '没有 Bot', 'Paste or type the key': '粘贴或输入密钥', 'Only {name}': '只给 {name}', -@@ -501,7 +501,6 @@ const ZH = { +@@ -501,7 +925,6 @@ 'At least {count} characters.': '至少 {count} 个字符。', 'Replace value': '替换值', 'Saving…': '保存中…', @@ -1255,7 +1258,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Allowing…': '允许中…', Allow: '允许', '{bot} asked for {name}': '{bot} 请求了 {name}', -@@ -528,7 +528,7 @@ const ZH = { +@@ -528,7 +951,7 @@ 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', 'What it is for': '用途', Value: '值', @@ -1264,7 +1267,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Save key': '保存密钥', 'Keys your Bots asked for. Bots never see the values: they use them as ': '你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ', ' in their shell, and every value is replaced by ': ' 使用,而且发给模型之前,每个值都会被替换成 ', -@@ -568,7 +568,6 @@ const ZH = { +@@ -568,7 +991,6 @@ Remove: '删除', 'Ask or update': '询问或更新', "Ask about or update {name}'s memory": '询问或更新 {name} 的记忆', @@ -1272,7 +1275,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Added: {text}': '已添加:{text}', 'Added to team memory: {text}': '已加入团队记忆:{text}', 'Updated: {text}': '已更新:{text}', -@@ -697,11 +697,11 @@ const ZH = { +@@ -697,11 +1119,11 @@ 'Compared with the {count} days before': '与再往前 {count} 天相比', '{share}% of all usage from {span}': '占 {span} 全部用量的 {share}%', // About @@ -1287,7 +1290,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Updates: '更新', 'Check for updates': '检查更新', 'Checking…': '正在检查…', -@@ -711,6 +711,13 @@ const ZH = { +@@ -711,6 +1133,13 @@ 'Update checks are off.': '已关闭更新检查。', 'Installed from a local folder; update it there.': '从本地文件夹安装,请在那里更新。', 'Updates are not offered for this install.': '这种安装方式不提供更新。', @@ -1301,7 +1304,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Links: '链接', 'Source code': '源代码', 'Release notes': '发布说明', -@@ -726,7 +726,7 @@ const ZH = { +@@ -726,7 +1155,7 @@ Show: '查看', Hide: '收起', 'GitHub issue': 'GitHub issue', @@ -1310,7 +1313,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Report on GitHub': '在 GitHub 上报告', 'Copied the diagnostics.': '已复制诊断信息。', 'Could not copy. Select the text below and copy it.': '无法复制。请选中下面的文字手动复制。', -@@ -738,7 +738,7 @@ const ZH = { +@@ -738,7 +1167,7 @@ 'Recent errors': '最近的错误', 'Recent errors · {count}': '最近的错误 · {count}', 'No errors recorded.': '没有错误记录。', @@ -1319,7 +1322,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Model call': '模型调用', 'This window': '这个窗口', 'Error log': '错误日志', -@@ -771,12 +771,21 @@ const ZH = { +@@ -771,12 +1200,21 @@ 'Add at least one more Bot': '再至少添加一个 Bot', Members: '成员', // Connectors and scheduled tasks @@ -1341,7 +1344,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Once, {time}': '一次,{time}', 'Every day at {time}': '每天 {time}', 'Every {days} at {time}': '每{days} {time}', -@@ -784,7 +784,21 @@ const ZH = { +@@ -784,7 +1222,21 @@ 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', @@ -1363,7 +1366,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'in {name}': '在 {name}', 'Click again to delete': '再点一次删除', 'Delete task': '删除任务', -@@ -794,6 +794,7 @@ const ZH = { +@@ -794,6 +1246,7 @@ minutes: '分钟', hours: '小时', days: '天', @@ -1371,7 +1374,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Morning briefing': '早间简报', 'What to do': '要做什么', 'Sum up what changed since yesterday and what needs me today.': '总结一下昨天以来的变化,以及今天需要我处理的事。', -@@ -806,9 +806,20 @@ const ZH = { +@@ -806,9 +1259,20 @@ Unit: '单位', 'Create task': '创建任务', 'Turn on Automation tasks in DSH': '在 DSH 里打开「自动化任务」', @@ -1394,7 +1397,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No scheduled tasks yet.': '还没有定时任务。', 'No scheduled tasks yet. Add one': '还没有定时任务,去添加', 'Automation tasks are off in DSH': 'DSH 的「自动化任务」没有打开', -@@ -819,10 +819,19 @@ const ZH = { +@@ -819,10 +1283,19 @@ 'Pick a date and time': '选一个日期和时间', 'Pick a time in the future': '选一个将来的时间', 'Pick a time of day': '选一个时间', @@ -1414,7 +1417,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', -@@ -838,6 +838,32 @@ const ZH = { +@@ -838,6 +1311,32 @@ 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', @@ -1447,7 +1450,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js } // Host errors stay English, since Bots read them too; they are translated where the -@@ -853,8 +853,11 @@ const HOST_ERRORS = [ +@@ -853,8 +1352,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, '只有你、主 Bot 或「$1」的管理员可以修改'], [/^A key must have at least (\d+) characters$/, '密钥至少 $1 个字符'], [/^A key can have at most (\d+) characters$/, '密钥最多 $1 个字符'], @@ -1459,7 +1462,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js [/^HTTP (\d+)$/, '请求失败(HTTP $1)'], [/^GitHub answered (\d+)$/, 'GitHub 返回了 $1'], [/^Keep the name under (\d+) characters$/, '名字不能超过 $1 个字'], -@@ -883,6 +883,20 @@ const HOST_ZH = { +@@ -883,6 +1385,20 @@ 'Unknown Bot': '找不到这个 Bot', 'Unknown conversation': '找不到这个对话', 'A group chat needs at least two Bots': '群聊至少要两个 Bot', @@ -1480,7 +1483,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'The admin must be a member of the group': '管理员必须是群成员', 'Unknown group chat': '找不到这个群聊', 'Hand the admin role to another member instead of leaving the group without one': '请把管理员交给其他成员,不要让群聊没有管理员', -@@ -905,6 +905,8 @@ const HOST_ZH = { +@@ -905,6 +1421,8 @@ 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', @@ -1489,6 +1492,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js } Object.assign(ZH, HOST_ZH) + diff --git a/src/client/member-status.js b/src/client/member-status.js --- a/src/client/member-status.js +++ b/src/client/member-status.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 37b757938..07ceb4865 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -709,7 +709,7 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); -@@ -50,12 +50,88 @@ +@@ -50,12 +79,88 @@ } } const readOnly = () => { @@ -799,7 +799,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ +@@ -87,10 +192,32 @@ return undefined } if (parsed?.version === 1) { @@ -833,7 +833,7 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,20 @@ +@@ -104,6 +231,20 @@ // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -854,7 +854,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true -@@ -112,6 +112,11 @@ +@@ -112,6 +253,11 @@ migrated = true } for (const bot of Object.values(state.bots)) { @@ -866,7 +866,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (!Array.isArray(bot.capabilities)) { bot.capabilities = [] migrated = true -@@ -120,6 +120,11 @@ +@@ -120,6 +266,11 @@ bot.color = currentColor(bot.color) migrated = true } @@ -878,7 +878,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,384 @@ +@@ -161,12 +312,390 @@ migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1215,9 +1215,9 @@ diff --git a/src/host/store.js b/src/host/store.js + return { project: readProject(project.id), ...(result ?? {}) } + } + case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) } -+ // The read-only board (#1696, #1767): Project id in, its cards and the record beside them -+ // out. The aggregate lives in project-board.js; it reads and returns, and writes nothing. -+ // A host that has not installed it answers empty sections, never a missing key. ++ // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in ++ // project-board.js; it reads and returns, and writes nothing. The actor goes with the id ++ // so the board applies the same visibility rule `tasks` does (#1723). + case 'project-board': { + const board = (await rt.projectBoard?.(projectText(body.id, 200), actor)) ?? [] + const rows = Array.isArray(board) ? board : (board.rows ?? []) @@ -1226,6 +1226,12 @@ diff --git a/src/host/store.js b/src/host/store.js + tasks: rows, + decisions: sections.decisions ?? { dir: null, files: [], reason: 'the board reads no decisions' }, + meetings: sections.meetings ?? { decided: [], pending: [], reason: 'the board reads no meetings' }, ++ // The queue/lease/blocked groups (#1768) ride along with the sections: one read of the ++ // same Project, so the header counts and the cards cannot disagree. ++ waiting: sections.waiting ?? [], ++ inflight: sections.inflight ?? [], ++ blocked: sections.blocked ?? [], ++ counts: sections.counts ?? { waiting: null, inflight: null, blocked: null, done: null }, + } + } + default: throw projectRefusal(`Unknown endpoint ${endpoint}`, 'bot/not-found') @@ -1264,7 +1270,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ +@@ -185,14 +714,24 @@ for (const room of stale) void rt.retitleRoom(room) })() @@ -1290,7 +1296,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ +@@ -235,11 +774,37 @@ return warnReadOnly() } @@ -1328,7 +1334,7 @@ diff --git a/src/host/store.js b/src/host/store.js state.revision += 1 state.exchanges = state.exchanges.slice(-MAX_EXCHANGES) const snapshot = JSON.stringify(state, null, 2) -@@ -260,9 +260,10 @@ +@@ -260,9 +825,10 @@ ensureLock() await rename(temp, statePath) seen = stamp() @@ -1340,7 +1346,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ +@@ -273,13 +839,37 @@ }) } @@ -1380,7 +1386,7 @@ diff --git a/src/host/store.js b/src/host/store.js const isMain = id => id !== undefined && state.mainBotIds.includes(id) // Group Sessions. A member speaks in each group from a hidden Session of its own, -@@ -313,6 +313,13 @@ +@@ -313,6 +903,13 @@ } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1394,7 +1400,7 @@ diff --git a/src/host/store.js b/src/host/store.js // The agents module owns the DSH Agent session index; it installs after this. rt.indexAgents?.() } -@@ -328,6 +328,8 @@ +@@ -328,6 +925,8 @@ delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1403,7 +1409,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ +@@ -335,23 +934,29 @@ } const findBot = (ref) => { if (ref === undefined) return undefined diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c3c48f21e..fd25587fd 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -42,6 +42,57 @@ module.exports = __toCommonJS(index_exports); +@@ -42,6 +42,57 @@ // src/client/i18n.js var NAMESPACE = "ds-bot"; var ZH = { @@ -61,7 +61,7 @@ diff --git a/client.js b/client.js // Shared "Main Bot": "主 Bot", Working: "工作中", -@@ -49,6 +49,67 @@ var ZH = { +@@ -49,6 +100,67 @@ "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -129,7 +129,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +63,21 @@ var ZH = { +@@ -63,6 +175,21 @@ Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -151,7 +151,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,72 @@ var ZH = { +@@ -78,21 +205,72 @@ " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -227,7 +227,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,59 @@ var ZH = { +@@ -129,6 +307,59 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -287,7 +287,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,64 @@ var ZH = { +@@ -141,17 +372,64 @@ "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -356,7 +356,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +162,6 @@ var ZH = { +@@ -162,8 +440,6 @@ "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -365,7 +365,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +187,24 @@ var ZH = { +@@ -187,9 +463,24 @@ Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -390,7 +390,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -205,6 +205,16 @@ var ZH = { +@@ -205,6 +496,16 @@ "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", @@ -407,7 +407,7 @@ diff --git a/client.js b/client.js "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", -@@ -247,6 +247,25 @@ var ZH = { +@@ -247,6 +548,25 @@ "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -433,7 +433,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +270,7 @@ var ZH = { +@@ -270,6 +590,7 @@ "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -441,7 +441,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +303,8 @@ var ZH = { +@@ -303,6 +624,8 @@ "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -450,7 +450,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +374,10 @@ var ZH = { +@@ -374,9 +697,10 @@ "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -463,7 +463,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +385,6 @@ var ZH = { +@@ -385,7 +709,6 @@ Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -471,7 +471,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +394,8 @@ var ZH = { +@@ -394,6 +717,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -480,7 +480,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +404,13 @@ var ZH = { +@@ -404,13 +729,13 @@ "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -500,7 +500,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,74 @@ var ZH = { +@@ -420,8 +745,77 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -555,6 +555,9 @@ diff --git a/client.js b/client.js + Low: "较低", + Claimed: "已认领", + Blocked: "受阻", ++ Waiting: "等待", ++ "In flight": "进行中", ++ unknown: "未知", + Start: "开始", + Complete: "完成", + "Start task": "开始任务", @@ -576,7 +579,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,13 @@ var ZH = { +@@ -446,10 +840,13 @@ Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -590,7 +593,7 @@ diff --git a/client.js b/client.js "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", -@@ -459,6 +459,9 @@ var ZH = { +@@ -459,6 +856,9 @@ "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -600,7 +603,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +468,8 @@ var ZH = { +@@ -468,6 +868,8 @@ "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -609,7 +612,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +495,6 @@ var ZH = { +@@ -495,7 +897,6 @@ "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -617,7 +620,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,31 @@ var ZH = { +@@ -506,12 +907,31 @@ "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -650,7 +653,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -533,13 +533,14 @@ var ZH = { +@@ -533,13 +953,14 @@ Selected: "已选", Skip: "跳过", Submit: "提交", @@ -666,7 +669,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +555,6 @@ var ZH = { +@@ -555,7 +976,6 @@ "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -674,7 +677,7 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -582,7 +582,7 @@ var ZH = { +@@ -582,7 +1002,7 @@ "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -683,7 +686,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -621,7 +621,6 @@ var ZH = { +@@ -621,7 +1041,6 @@ Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", @@ -691,7 +694,7 @@ diff --git a/client.js b/client.js "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", -@@ -747,11 +747,11 @@ var ZH = { +@@ -747,11 +1166,11 @@ "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -706,7 +709,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ var ZH = { +@@ -761,6 +1180,13 @@ "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -720,7 +723,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +776,7 @@ var ZH = { +@@ -776,7 +1202,7 @@ Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -729,7 +732,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +788,7 @@ var ZH = { +@@ -788,7 +1214,7 @@ "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -738,7 +741,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,12 +821,21 @@ var ZH = { +@@ -821,12 +1247,21 @@ "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -760,7 +763,7 @@ diff --git a/client.js b/client.js "Once, {time}": "一次,{time}", "Every day at {time}": "每天 {time}", "Every {days} at {time}": "每{days} {time}", -@@ -834,7 +834,21 @@ var ZH = { +@@ -834,7 +1269,21 @@ "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -782,7 +785,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -844,6 +844,7 @@ var ZH = { +@@ -844,6 +1293,7 @@ minutes: "分钟", hours: "小时", days: "天", @@ -790,7 +793,7 @@ diff --git a/client.js b/client.js "Morning briefing": "早间简报", "What to do": "要做什么", "Sum up what changed since yesterday and what needs me today.": "总结一下昨天以来的变化,以及今天需要我处理的事。", -@@ -856,9 +856,20 @@ var ZH = { +@@ -856,9 +1306,20 @@ Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -813,7 +816,7 @@ diff --git a/client.js b/client.js "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", "Automation tasks are off in DSH": "DSH 的「自动化任务」没有打开", -@@ -869,10 +869,19 @@ var ZH = { +@@ -869,10 +1330,19 @@ "Pick a date and time": "选一个日期和时间", "Pick a time in the future": "选一个将来的时间", "Pick a time of day": "选一个时间", @@ -833,7 +836,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +887,33 @@ var ZH = { +@@ -887,7 +1357,33 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -868,7 +871,7 @@ diff --git a/client.js b/client.js }; var HOST_ERRORS = [ [/^Model (.+) is not available\. Available: (.*)$/, "模型 $1 不可用。可用的:$2"], -@@ -900,8 +900,11 @@ var HOST_ERRORS = [ +@@ -900,8 +1396,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, "只有你、主 Bot 或「$1」的管理员可以修改"], [/^A key must have at least (\d+) characters$/, "密钥至少 $1 个字符"], [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], @@ -880,7 +883,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -930,6 +930,20 @@ var HOST_ZH = { +@@ -930,6 +1429,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -901,7 +904,7 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,10 +951,12 @@ var HOST_ZH = { +@@ -951,10 +1464,12 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -916,7 +919,7 @@ diff --git a/client.js b/client.js var service = null; var bound = null; function installLocale(locale) { -@@ -1005,15 +1005,23 @@ function noteClientError(where, error) { +@@ -1005,15 +1520,23 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -943,7 +946,7 @@ diff --git a/client.js b/client.js if (entries.length > MAX) entries.splice(0, entries.length - MAX); } var quietly = (where) => (error) => noteClientError(where, error); -@@ -1042,16 +1042,21 @@ var FAILURES = { +@@ -1042,16 +1565,21 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -971,7 +974,7 @@ diff --git a/client.js b/client.js const full2 = build(diagnostics); if (full2.length <= URL_LIMIT) return { url: full2, inline: true }; const facts = build(`${diagnostics.split("\n\n")[0]} -@@ -1066,10 +1066,16 @@ var REFUSALS = /* @__PURE__ */ new Set(["bot/refused", "bot/invalid", "bot/read- +@@ -1066,10 +1594,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -989,7 +992,7 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { +@@ -1087,7 +1621,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -998,7 +1001,7 @@ diff --git a/client.js b/client.js if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); -@@ -1097,6 +1097,388 @@ var call = async (endpoint, payload, signal) => { +@@ -1097,6 +1631,388 @@ return result.value; }; @@ -1387,7 +1390,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1160,9 +1160,9 @@ var MAIN_LOOK = Object.freeze({ shape: "whale", color: "deepseek" }); +@@ -1160,9 +2076,9 @@ var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -1399,7 +1402,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +1177,96 @@ function defaultShape(id) { +@@ -1177,19 +2093,96 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1499,7 +1502,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +2199,8 @@ return ""; } } @@ -1510,7 +1513,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,192 +1242,46 @@ function maskOf(shape) { +@@ -1242,192 +2235,46 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1722,7 +1725,7 @@ diff --git a/client.js b/client.js const skip = [ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ...referenceSpans(value, references) -@@ -1445,24 +1445,86 @@ function mentionSpans(text, bots, { references = [] } = {}) { +@@ -1445,24 +2292,86 @@ } return spans; } @@ -1814,7 +1817,7 @@ diff --git a/client.js b/client.js function clock(ms) { return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -@@ -1483,9 +1483,18 @@ function relativeShort(ms) { +@@ -1483,9 +2392,18 @@ var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; var currentPart = (bot) => bot.sessionId ?? bot.id; @@ -1836,7 +1839,7 @@ diff --git a/client.js b/client.js try { const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); -@@ -1493,38 +1493,121 @@ function reminderPrompts(text) { +@@ -1493,38 +2411,121 @@ return []; } } @@ -1965,7 +1968,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,17 +1597,46 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1597,17 +2598,46 @@ } return { count, peerIds }; } @@ -2015,7 +2018,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,9 +1624,982 @@ function usageOf(list, ids) { +@@ -1624,9 +2654,982 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2999,7 +3002,7 @@ diff --git a/client.js b/client.js var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; function springEasing(stiffness, damping) { const w0 = Math.sqrt(stiffness); -@@ -1659,12 +1659,12 @@ function liveSprings(apply2) { +@@ -1659,12 +3662,12 @@ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); @@ -3015,7 +3018,7 @@ diff --git a/client.js b/client.js } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; -@@ -1699,10 +1699,10 @@ function liveSprings(apply2) { +@@ -1699,10 +3702,10 @@ } var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { @@ -3029,7 +3032,7 @@ diff --git a/client.js b/client.js if (own.current === null) { own.current = { key: void 0, -@@ -1736,7 +1736,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1736,7 +3739,7 @@ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); container.dataset.glide = ""; }; @@ -3038,7 +3041,7 @@ diff --git a/client.js b/client.js const state = own.current; const node = box.current; if (node === state.node && key === state.key) return; -@@ -1750,7 +1750,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1750,7 +3753,7 @@ } place(fresh); }); @@ -3047,7 +3050,7 @@ diff --git a/client.js b/client.js own.current.observer?.disconnect(); own.current.springs.stop(); }, []); -@@ -1758,16 +1758,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai +@@ -1758,16 +3761,16 @@ } function Segmented({ label, value, className, children }) { const glide = useGlide(value); @@ -3068,7 +3071,7 @@ diff --git a/client.js b/client.js const container = list.current; if (!container) return; const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +1788,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +@@ -1788,8 +3791,8 @@ }, [key]); } function useMorph(box, key) { @@ -3079,7 +3082,7 @@ diff --git a/client.js b/client.js const node = box.current; const state = last.current; if (!node) { -@@ -1817,8 +1817,8 @@ function useMorph(box, key) { +@@ -1817,8 +3820,8 @@ }); } function useLinger(value, ms) { @@ -3090,7 +3093,7 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; -@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- +@@ -1838,6 +3841,7 @@ .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} @@ -3098,7 +3101,7 @@ diff --git a/client.js b/client.js .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo +@@ -1856,6 +3860,14 @@ @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} @@ -3113,7 +3116,7 @@ diff --git a/client.js b/client.js @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1933,15 +3945,23 @@ .bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} .bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -3139,7 +3142,7 @@ diff --git a/client.js b/client.js .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -@@ -1950,27 +1950,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1950,27 +3970,32 @@ .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3176,7 +3179,7 @@ diff --git a/client.js b/client.js .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} -@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +@@ -1992,6 +4017,7 @@ .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} @@ -3184,7 +3187,7 @@ diff --git a/client.js b/client.js .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2085,6 +4111,7 @@ @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} @@ -3192,7 +3195,7 @@ diff --git a/client.js b/client.js .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} .bt-to-label{color:var(--bt-ink-2)} .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2098,7 +4125,9 @@ .bt-option[aria-selected=true]{background:var(--bt-active)} .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -3203,7 +3206,7 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} -@@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2127,6 +4156,10 @@ .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} @@ -3214,7 +3217,7 @@ diff --git a/client.js b/client.js .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} -@@ -2150,11 +2150,11 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2150,11 +4183,11 @@ .bt-soft:hover{background:var(--bt-active)} .bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} @@ -3228,7 +3231,7 @@ diff --git a/client.js b/client.js .bt-panel-grip{position:absolute;left:-5px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:3;touch-action:none} .bt-panel-grip:hover,.bt-panel[data-dragging] .bt-panel-grip{background:linear-gradient(to right,transparent 4px,var(--bt-line-2) 4px,var(--bt-line-2) 6px,transparent 6px)} .bt-drag-shield{position:fixed;inset:0;z-index:45;cursor:col-resize} -@@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2163,6 +4196,8 @@ .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} .bt-drawer-name{font-size:17px;line-height:24px;font-weight:500;border:0;background:none;color:inherit;cursor:text;padding:0 6px;border-radius:6px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -3237,7 +3240,7 @@ diff --git a/client.js b/client.js .bt-drawer-name:hover{background:var(--bt-hover)} .bt-drawer-name-input{font-size:17px;line-height:24px;text-align:center;border:.5px solid var(--bt-line-2);border-radius:6px;padding:0 6px;background:var(--bt-main);color:var(--bt-ink);outline:none;font-weight:500} .bt-drawer-role{font-size:12px;line-height:16px;color:var(--bt-ink-3);margin-top:6px;text-align:center} -@@ -2197,7 +2197,7 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2197,7 +4232,7 @@ .bt-avatar-anchor{position:relative} .bt-avatar-trigger{position:relative;width:64px;height:64px;border:0;border-radius:50%;background:none;cursor:pointer;padding:0} .bt-avatar-pencil{position:absolute;right:-2px;bottom:-2px;width:22px;height:22px;border-radius:50%;background:var(--bt-main);border:1px solid var(--bt-line-2);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;box-shadow:0 1px 4px rgba(0,0,0,.12)} @@ -3246,7 +3249,7 @@ diff --git a/client.js b/client.js .bt-bs .bt-avedit{transform-origin:32px 0} .bt-avedit-top{display:flex;align-items:center;justify-content:space-between} .bt-avedit-tabs{position:relative;display:flex;gap:2px;background:var(--bt-hover);border-radius:999px;padding:2px} -@@ -2237,9 +2237,23 @@ button.bt-q-opt:active{background:var(--bt-active)} +@@ -2237,9 +4272,23 @@ .bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} /* Settings dialog: a 198px page list beside the page. */ @@ -3271,7 +3274,7 @@ diff --git a/client.js b/client.js .bt-settings{--bt-set-w:min(800px,calc(100vw - 48px));--bt-set-h:min(620px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));position:relative;width:var(--bt-set-w);height:var(--bt-set-h);background:var(--bt-main);border-radius:16px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;overflow:hidden;color:var(--bt-ink);transition:${spring(SPRING_SHAPE, "width", "height")};animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-settings[data-wide]{--bt-set-w:min(1100px,calc(100vw - 48px));--bt-set-h:min(780px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))))} :is(.bt-settings-layer,.bt-mem-layer)[data-leaving]{pointer-events:none} -@@ -2255,6 +2255,7 @@ body[data-ds-dark-theme] .bt-scrim{background:rgba(0,0,0,.5)} +@@ -2255,6 +4304,7 @@ .bt-settings-nav>.bt-thumb{left:8px;right:8px;border-radius:8px;background:var(--bt-active)} .bt-settings-nav[data-glide] .bt-settings-item[aria-current=page]{background:transparent} .bt-settings-shell{margin-top:auto} @@ -3279,7 +3282,7 @@ diff --git a/client.js b/client.js /* Usage while another page is open: out of the flow at Usage's own width, so it keeps its layout, and not rendered. */ .bt-settings-usage[data-off]{position:absolute;top:0;left:198px;width:calc(min(1100px,calc(100vw - 48px)) - 198px);height:100%;box-sizing:border-box;content-visibility:hidden;pointer-events:none} -@@ -2288,13 +2288,63 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2288,13 +4338,63 @@ .bt-set-card>.bt-set-row+.bt-set-row{border-top:.5px solid var(--bt-line)} .bt-set-row:first-child{border-top-left-radius:12px;border-top-right-radius:12px} .bt-set-row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px} @@ -3343,7 +3346,7 @@ diff --git a/client.js b/client.js /* Settings → Connectors. */ .bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -@@ -2308,8 +2308,17 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2308,8 +4408,17 @@ .bt-connector-actions{flex:none;display:flex;gap:6px} .bt-connector-form{display:flex;flex-direction:column;gap:8px;padding-left:48px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-connector-form-row{display:flex;align-items:center;gap:6px} @@ -3361,7 +3364,7 @@ diff --git a/client.js b/client.js /* Shell chrome reshaped for the team view. Selectors use stable data attributes where the shell offers them and CSS-module name suffixes elsewhere. */ -@@ -2361,7 +2361,7 @@ button.bt-set-row:hover{background:var(--bt-hover)} +@@ -2361,7 +4470,7 @@ .bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} .bt-tool:active{background:var(--bt-active)} .bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} @@ -3370,7 +3373,7 @@ diff --git a/client.js b/client.js .bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} .bt-react-strip button:nth-child(2){animation-delay:20ms} .bt-react-strip button:nth-child(3){animation-delay:40ms} -@@ -2380,7 +2380,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2380,7 +4489,7 @@ .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3379,7 +3382,7 @@ diff --git a/client.js b/client.js .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} .bt-menu button:hover{background:var(--bt-hover)} .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} -@@ -2390,7 +2390,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2390,7 +4499,7 @@ .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} @@ -3388,7 +3391,7 @@ diff --git a/client.js b/client.js .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-palette-layer[data-leaving]{pointer-events:none} -@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side +@@ -2427,7 +4536,8 @@ .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} @@ -3398,7 +3401,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2494,7 +2494,7 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2494,7 +4604,7 @@ .bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} .bt-mem-row-copy{flex:1;text-align:left} .bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} @@ -3407,7 +3410,7 @@ diff --git a/client.js b/client.js .bt-mem-scrim{animation:bt-fade .2s ease-out} .bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} .bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} -@@ -2597,9 +2597,20 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2597,9 +4707,20 @@ `; var CONVERSATION_CSS = ` .bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} @@ -3430,7 +3433,7 @@ diff --git a/client.js b/client.js .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} .bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} -@@ -2612,7 +2612,6 @@ var CONVERSATION_CSS = ` +@@ -2612,7 +4733,6 @@ .bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} [class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} @@ -3438,7 +3441,7 @@ diff --git a/client.js b/client.js .bt-group-replies{display:contents} /* A Bot chat is flat in every work-details mode, without changing the user's mode (it is a DSH-wide setting): step groups lose their header and stay open, and what a -@@ -2622,21 +2622,48 @@ var CONVERSATION_CSS = ` +@@ -2622,21 +4742,48 @@ [data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} :is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} [class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} @@ -3495,7 +3498,7 @@ diff --git a/client.js b/client.js [data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} [data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} [data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} -@@ -2646,8 +2646,19 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2646,8 +4793,19 @@ [data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} [data-chat-running]>span:not([role=status]){display:none!important} [data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} @@ -3515,7 +3518,7 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2679,7 +4837,8 @@ @keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @keyframes bt-fade-out{from{opacity:1}to{opacity:0}} [data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} @@ -3525,7 +3528,7 @@ diff --git a/client.js b/client.js [data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} -@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2691,6 +4850,8 @@ [data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} [data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} [data-composer-placeholder]{font-size:0!important;line-height:0!important} @@ -3534,7 +3537,7 @@ diff --git a/client.js b/client.js [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ [data-composer-card] button[class*="_add"]{display:none!important} -@@ -2722,7 +2722,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2722,7 +4883,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} @@ -3544,7 +3547,7 @@ diff --git a/client.js b/client.js .bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} .bt-q{order:1} -@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2776,6 +4939,7 @@ .bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} .bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} .bt-urow{display:flex;justify-content:flex-end} @@ -3552,7 +3555,7 @@ diff --git a/client.js b/client.js .bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} .bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} .bt-ububble p{margin:0;line-height:22px} -@@ -2784,10 +2784,10 @@ div:has(> nav [class*="_marks"]){display:none!important} +@@ -2784,10 +4948,10 @@ .bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} .bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} `; @@ -3565,7 +3568,7 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2807,9 +2807,9 @@ function sheetClassNames(sheet) { +@@ -2807,9 +4971,9 @@ } return names; } @@ -3577,7 +3580,7 @@ diff --git a/client.js b/client.js if (!compiled.has(fragment)) { const exact = []; for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -@@ -2818,7 +2818,7 @@ function compileFragmentSelectors(text, names) { +@@ -2818,7 +4982,7 @@ return compiled.get(fragment); }); } @@ -3586,7 +3589,7 @@ diff --git a/client.js b/client.js const style = installStyles("", tag); const cache = /* @__PURE__ */ new WeakMap(); const render = () => { -@@ -2832,7 +2832,7 @@ function installCompiledStyles(text, tag) { +@@ -2832,7 +4996,7 @@ } for (const name of list) names.add(name); } @@ -3595,7 +3598,7 @@ diff --git a/client.js b/client.js if (style.textContent !== next) style.textContent = next; }; render(); -@@ -2855,226 +2855,204 @@ function installCompiledStyles(text, tag) { +@@ -2855,226 +5019,204 @@ }; } @@ -3675,7 +3678,23 @@ diff --git a/client.js b/client.js -} -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 })); --} ++// src/shared/face-title.js ++var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; ++function isGenericFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return !n || n === "Face-to-face room"; ++} ++function formatFaceWhen(value) { ++ const date = value instanceof Date ? value : new Date(value); ++ if (!Number.isFinite(date.getTime())) return ""; ++ const hh = String(date.getHours()).padStart(2, "0"); ++ const mm = String(date.getMinutes()).padStart(2, "0"); ++ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; ++} ++function isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); + } -function MainStar() { - return (0, import_react2.createElement)( - "svg", @@ -3699,23 +3718,28 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; -+// src/shared/face-title.js -+var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -+function isGenericFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return !n || n === "Face-to-face room"; -+} -+function formatFaceWhen(value) { -+ const date = value instanceof Date ? value : new Date(value); -+ if (!Number.isFinite(date.getTime())) return ""; -+ const hh = String(date.getHours()).padStart(2, "0"); -+ const mm = String(date.getMinutes()).padStart(2, "0"); -+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; -+} -+function isAutoFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); -+} +-} +-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 +- ); +-} +-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 ownersFromMembers(members) { + const rows = []; + const seen = /* @__PURE__ */ new Set(); @@ -3726,22 +3750,42 @@ diff --git a/client.js b/client.js + seen.add(key); + const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); + rows.push({ owner, sort: handle || key }); -+ } + } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); } --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; +-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-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 +- { 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}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) - ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); @@ -3776,14 +3820,7 @@ diff --git a/client.js b/client.js + if (people2.length) bits.push(people2.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); - } --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]]; ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -3805,26 +3842,12 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById?.[sessionId]; + if (bot?.remote) { + return friendBotTitle(bot); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); + if (room2) return roomTitle(room2); + return bot?.name || ""; - } --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 installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -3854,25 +3877,7 @@ diff --git a/client.js b/client.js + link.rel = "icon"; + link.type = "image/svg+xml"; + document.head.appendChild(link); - } -- 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}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } + link.href = href; + const retitle = () => { + const next = brandTitle(document.title); @@ -3975,7 +3980,7 @@ diff --git a/client.js b/client.js ); } var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -@@ -3086,6 +3086,7 @@ var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]) +@@ -3086,6 +5228,7 @@ var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); @@ -3983,7 +3988,7 @@ diff --git a/client.js b/client.js var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); -@@ -3093,14 +3093,14 @@ var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 +@@ -3093,14 +5236,14 @@ var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); function PinMarkIcon() { @@ -4002,7 +4007,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -3112,21 +3112,48 @@ var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h +@@ -3112,21 +5255,48 @@ var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); @@ -4054,7 +4059,7 @@ diff --git a/client.js b/client.js if (count === 0) return void 0; const onKey = (event) => { if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -@@ -3146,10 +3146,10 @@ function useOptionKeys(count, onPick, ownRef) { +@@ -3146,10 +5316,10 @@ }, [count, ownRef]); } function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { @@ -4069,7 +4074,7 @@ diff --git a/client.js b/client.js const options = optionsOf(question); const multi = question.multiSelect === true; const custom = own.trim(); -@@ -3167,7 +3167,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3167,7 +5337,7 @@ }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; @@ -4078,7 +4083,7 @@ diff --git a/client.js b/client.js ref: ownRef, rows: 1, value: own, -@@ -3186,7 +3186,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3186,7 +5356,7 @@ }) : null; const rows = options.map((option, optionIndex) => { const selected = multi && picked.includes(option.label); @@ -4087,7 +4092,7 @@ diff --git a/client.js b/client.js "button", { key: option.label, -@@ -3196,36 +3196,36 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3196,36 +5366,36 @@ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), onClick: () => choose(option.label) }, @@ -4137,7 +4142,7 @@ diff --git a/client.js b/client.js "form", { className: raised ? "bt-q bt-q-raised" : "bt-q", -@@ -3235,24 +3235,24 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised +@@ -3235,24 +5405,24 @@ submit(); } }, @@ -4172,7 +4177,7 @@ diff --git a/client.js b/client.js ) : null, foot ); -@@ -3262,29 +3262,29 @@ function SettledQuestion({ question, answer }) { +@@ -3262,29 +5432,29 @@ const prompt = question?.question ?? ""; const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; if (chosen.length === 0) { @@ -4213,7 +4218,7 @@ diff --git a/client.js b/client.js ); })) ); -@@ -3292,8 +3292,8 @@ function SettledQuestion({ question, answer }) { +@@ -3292,8 +5462,8 @@ function QuestionCard({ matched }) { const pending = matched; const questions = pending.questions ?? []; @@ -4224,7 +4229,7 @@ diff --git a/client.js b/client.js const question = questions[index]; if (question === void 0) return null; const onAnswer = (answer) => { -@@ -3305,10 +3305,10 @@ function QuestionCard({ matched }) { +@@ -3305,10 +5475,10 @@ } pending.answer({ answers: next }); }; @@ -4237,7 +4242,7 @@ diff --git a/client.js b/client.js if (answered === question.id) return null; const onAnswer = (answer) => { setAnswered(question.id); -@@ -3318,10 +3318,10 @@ function PendingQuestion({ sessionId, question, actions, inline }) { +@@ -3318,10 +5488,10 @@ setAnswered(question.id); void actions.dismissQuestion(sessionId); }; @@ -4250,7 +4255,7 @@ diff --git a/client.js b/client.js ); } function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -@@ -3329,16 +3329,16 @@ function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +5499,16 @@ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; const loading = useSession?.(historyLoading) === true; if (question === void 0 || question.callId && (drawn || loading)) return null; @@ -4270,7 +4275,7 @@ diff --git a/client.js b/client.js key: question.id, question, answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -@@ -3346,37 +3346,37 @@ function AnsweredCell({ node }) { +@@ -3346,37 +5516,37 @@ } // src/client/secret-card.js @@ -4320,7 +4325,7 @@ diff --git a/client.js b/client.js className: "bt-secret-input", type: MASK_BY_CSS ? "text" : "password", "data-masked": MASK_BY_CSS ? "" : void 0, -@@ -3401,42 +3401,42 @@ function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +@@ -3401,42 +5571,42 @@ }); } function ScopeSwitch({ value, onChange, bot }) { @@ -4380,7 +4385,7 @@ diff --git a/client.js b/client.js const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); const run = (promise) => { setBusy(true); -@@ -3452,73 +3452,73 @@ function SecretCard({ bot, request, roster, actions }) { +@@ -3452,73 +5622,73 @@ if (!busy) void run(actions.secretAllow(request.id)); }; const filling = request.kind === "fill" || replacing; @@ -4480,7 +4485,7 @@ diff --git a/client.js b/client.js } return null; } -@@ -3531,44 +3531,46 @@ function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3531,44 +5701,46 @@ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); if (waiting.length === 0) return null; @@ -4543,7 +4548,7 @@ diff --git a/client.js b/client.js const long = value.trim().length >= MIN_VALUE; const save = () => { if (!long) return; -@@ -3583,66 +3583,66 @@ function ValueEditor({ secret, onSave }) { +@@ -3583,66 +5755,66 @@ setValue(next); setReplaced(false); }; @@ -4636,7 +4641,7 @@ diff --git a/client.js b/client.js const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; const save = () => { if (!ready) return; -@@ -3653,43 +3653,43 @@ function AddSecret({ actions, run, onDone }) { +@@ -3653,43 +5825,43 @@ } }); }; @@ -4698,7 +4703,7 @@ diff --git a/client.js b/client.js ]; } var SECRET_CSS = ` -@@ -3756,6 +3756,144 @@ var SECRET_CSS = ` +@@ -3756,6 +5928,144 @@ @media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} `; @@ -4843,7 +4848,7 @@ diff --git a/client.js b/client.js // src/client/turn-reasoning.js function turnReasoning(steps, current) { const members = steps.length ? steps : [current]; -@@ -3767,7 +3767,7 @@ var thinkingLive = (step) => step.status === "running" && texts(step).length === +@@ -3767,7 +6077,7 @@ var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); // src/client/cells.js @@ -4852,7 +4857,7 @@ diff --git a/client.js b/client.js var import_react_dom = require("react-dom"); var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -@@ -3780,29 +3780,29 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3780,29 +6090,29 @@ actions.openSession(target); }; const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; @@ -4890,7 +4895,7 @@ diff --git a/client.js b/client.js key: emoji, type: "button", className: "bt-react", -@@ -3813,9 +3813,22 @@ function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, +@@ -3813,9 +6123,22 @@ }) ); } @@ -4914,7 +4919,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (refs.some((ref) => ref.current?.contains(event.target))) return; -@@ -3835,12 +3835,12 @@ function useDismiss(open, close, refs) { +@@ -3835,12 +6158,12 @@ }; }, [open]); } @@ -4933,7 +4938,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3854,36 +3854,36 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3854,36 +6177,36 @@ setPanel(kind); }; const copy2 = () => { @@ -4981,7 +4986,7 @@ diff --git a/client.js b/client.js "div", { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, item(t("Copy"), CopyIcon, copy2), -@@ -3891,12 +3891,40 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3891,12 +6214,40 @@ ), document.body) : null ); } @@ -5026,7 +5031,7 @@ diff --git a/client.js b/client.js ); } function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +3904,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,19 +6255,33 @@ const location2 = node.location; const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; const seq = node.data.finalNode?.seq; @@ -5062,7 +5067,7 @@ diff --git a/client.js b/client.js const running = data.status === "running"; useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); if (hidden) return null; -@@ -3924,13 +3924,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3924,13 +6289,14 @@ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; if (texts2.length === 0 && !reasoning && relays.length === 0) return null; const selfId = roster.byId[sessionId]?.id ?? sessionId; @@ -5080,7 +5085,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,230 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +6306,230 @@ whole: true }); }), @@ -5334,7 +5339,7 @@ diff --git a/client.js b/client.js ); } function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -4005,14 +4005,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +6540,14 @@ const target = other && other.id !== self?.id ? other : self; if (!target) return null; const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); @@ -5352,7 +5357,7 @@ diff --git a/client.js b/client.js } var scrollParent = (node) => { for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4021,11 @@ var scrollParent = (node) => { +@@ -4021,11 +6556,11 @@ return null; }; function EarlierParts({ sessionId, roster, actions }) { @@ -5369,7 +5374,7 @@ diff --git a/client.js b/client.js const load = (cursor) => { if (busy.current) return; busy.current = true; -@@ -4041,19 +4041,19 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4041,19 +6576,19 @@ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); }); }; @@ -5392,7 +5397,7 @@ diff --git a/client.js b/client.js const node = top.current; if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4069,35 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4069,35 +6604,35 @@ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); } @@ -5439,7 +5444,7 @@ diff --git a/client.js b/client.js if (selfId && other.id) actions.openExchange(selfId, other.id); }, whole: true }); } -@@ -4105,7 +4105,7 @@ function EarlierParts({ sessionId, roster, actions }) { +@@ -4105,7 +6640,7 @@ return null; } }), @@ -5448,7 +5453,7 @@ diff --git a/client.js b/client.js ); } function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4116,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4116,7 +6651,7 @@ const deferred = source.kind === "bot" && source.deferred === true; const relayed = isRelayContext(data); const turn = node.location?.turn?.turn ?? data.turn; @@ -5457,7 +5462,7 @@ diff --git a/client.js b/client.js if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); }, [relayed, sessionId, turn, source.senderSessionId]); const order = useSessions((list) => { -@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4135,23 +6670,31 @@ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); const body = triggerBody(); @@ -5496,7 +5501,7 @@ diff --git a/client.js b/client.js const run = relayed ? commRunOf( order.split(",").filter(Boolean).map(Number), Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4165,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4165,7 +6708,7 @@ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); } @@ -5505,7 +5510,7 @@ diff --git a/client.js b/client.js prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), bots, onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4174,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us +@@ -4174,24 +6717,24 @@ } if (source.kind === "schedule") { const prompts = reminderPrompts(contentText(data.content)); @@ -5535,7 +5540,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "first", start); }, [sessionId, turn, start]); const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4199,67 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu +@@ -4199,11 +6742,67 @@ 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; }); @@ -5604,7 +5609,7 @@ diff --git a/client.js b/client.js var SEPARATOR_GAP_MS = 30 * 60 * 1e3; function clockLabel(time, now = Date.now()) { const date = new Date(time); -@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { +@@ -4233,10 +6832,19 @@ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; @@ -5625,7 +5630,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime(sessionId, turn, "end", time); }, [sessionId, turn, time]); const label = useTurnClock((state) => { -@@ -4250,15 +4250,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions +@@ -4250,15 +6858,15 @@ return start !== void 0 && start > visit.seen && start <= visit.entered; }); if (!label && !fresh) return null; @@ -5646,7 +5651,7 @@ diff --git a/client.js b/client.js } var PHRASES = { thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +4320,22 @@ function toolActivity(name, arg, findBot, self) { +@@ -4320,19 +6928,22 @@ } function workPhrase(tool) { if (!tool) return t("Thinking about it"); @@ -5674,7 +5679,7 @@ diff --git a/client.js b/client.js if (done) return void 0; let stop = false; let timer; -@@ -4341,10 +4341,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4341,10 +6952,10 @@ const result = await actions.roomProgress(sessionId); if (stop) return; const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; @@ -5687,7 +5692,7 @@ diff --git a/client.js b/client.js } timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); }; -@@ -4362,71 +4362,89 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -4362,71 +6973,89 @@ replies = []; } } @@ -5808,7 +5813,7 @@ diff --git a/client.js b/client.js }) ); } -@@ -4435,52 +4435,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +@@ -4435,52 +7064,52 @@ function callTree(root) { const calls = []; const seen = /* @__PURE__ */ new Set(); @@ -5877,7 +5882,7 @@ diff --git a/client.js b/client.js case "create_group": case "update_group": case "delete_group": -@@ -4492,64 +4492,90 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot +@@ -4492,64 +7121,90 @@ post_to_group: ["Posted in", "Couldn't post in the group"] }[tool]; if (!done) return null; @@ -5986,7 +5991,7 @@ diff --git a/client.js b/client.js var import_react_dom2 = require("react-dom"); var PHRASE_HOLD_MS = 800; var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +4559,69 @@ function elapsedLabel(ms) { +@@ -4559,9 +7214,69 @@ const hours = Math.floor(minutes / 60); return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } @@ -6059,7 +6064,7 @@ diff --git a/client.js b/client.js if (!active) { setHost(null); return void 0; -@@ -4569,7 +4569,7 @@ function useRunningHost(active) { +@@ -4569,7 +7284,7 @@ let frame = 0; const find = () => { frame = 0; @@ -6068,7 +6073,7 @@ diff --git a/client.js b/client.js setHost((prev) => prev === node ? prev : node); }; const observer = new MutationObserver(() => { -@@ -4581,13 +4581,13 @@ function useRunningHost(active) { +@@ -4581,13 +7296,13 @@ cancelAnimationFrame(frame); observer.disconnect(); }; @@ -6085,7 +6090,7 @@ diff --git a/client.js b/client.js const seen = /* @__PURE__ */ new WeakSet(); const started = Date.now(); let column = null; -@@ -4638,21 +4638,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4638,21 +7353,31 @@ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; @@ -6124,7 +6129,7 @@ diff --git a/client.js b/client.js if (wantKey === null || shown?.key === wantKey) return void 0; const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,26 +4663,31 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4663,26 +7388,31 @@ const timer = setTimeout(make, wait); return () => clearTimeout(timer); }, [wantKey, shown]); @@ -6168,7 +6173,7 @@ diff --git a/client.js b/client.js setShown(null); const before = shift.current; shift.current = null; -@@ -4691,31 +4691,37 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us +@@ -4691,31 +7421,37 @@ const delta = before - scroller.scrollTop; if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); }, [phase]); @@ -6218,7 +6223,7 @@ diff --git a/client.js b/client.js ) ) ), host); -@@ -4731,49 +4731,1697 @@ function showsDshRow(dsh, app) { +@@ -4731,49 +7467,1697 @@ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } @@ -7929,7 +7934,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4785,55 +4785,131 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio +@@ -4785,55 +9169,131 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -8076,7 +8081,7 @@ diff --git a/client.js b/client.js "data-room-id": id, onClick: () => actions.openSession(id), onContextMenu: (event) => { -@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, +@@ -4841,20 +9301,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -8105,7 +8110,7 @@ diff --git a/client.js b/client.js if (!renaming) return; input.current?.focus(); input.current?.select(); -@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4875,10 +9335,10 @@ } })(); }; @@ -8118,7 +8123,7 @@ diff --git a/client.js b/client.js ref: input, defaultValue: session.title, "aria-label": t("Rename"), -@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4891,7 +9351,7 @@ }) ); } @@ -8127,7 +8132,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { +@@ -4905,8 +9365,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8138,7 +8143,7 @@ diff --git a/client.js b/client.js ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4941,10 +9401,10 @@ const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; @@ -8151,7 +8156,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4959,30 +9419,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -8193,7 +8198,7 @@ diff --git a/client.js b/client.js key: session.id, session, agent, -@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, +@@ -4996,8 +9456,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -8204,7 +8209,7 @@ diff --git a/client.js b/client.js const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); -@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { +@@ -5014,9 +9474,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -8250,7 +8255,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); -@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { +@@ -5025,17 +9520,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -8281,7 +8286,7 @@ diff --git a/client.js b/client.js useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); useLandOnMainBot(roster, useSessions, actions); const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -@@ -5045,39 +5045,42 @@ function TeamSidebar(props) { +@@ -5045,39 +9549,42 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -8344,7 +8349,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5116,14 +9623,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -8364,7 +8369,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-round bt-update", -@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5133,21 +9640,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -8397,7 +8402,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: phase === "installing", -@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5155,17 +9662,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -8422,7 +8427,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-update-go", disabled: dshPhase === "installing", -@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5176,61 +9683,25 @@ ), document.body) : null ); } @@ -8494,7 +8499,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { +@@ -5259,6 +9730,18 @@ else openHarnessSettings(); }); } @@ -8513,7 +8518,7 @@ diff --git a/client.js b/client.js function openThroughMenu(trigger) { const before = new Set(document.querySelectorAll('[role="menu"]')); trigger.click(); -@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { +@@ -5288,32 +9771,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -8554,7 +8559,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5341,30 +9824,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -8599,7 +8604,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi +@@ -5372,7 +9857,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -8608,7 +8613,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +9867,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -8619,7 +8624,7 @@ diff --git a/client.js b/client.js if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; -@@ -5397,40 +5397,40 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5397,40 +9882,40 @@ return void 0; } const last = lastBot.current; @@ -8670,7 +8675,7 @@ diff --git a/client.js b/client.js } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -5456,7 +5456,7 @@ var SIDEBAR_CSS = ` +@@ -5456,7 +9941,7 @@ .bt-update[data-phase=installing] .bt-cloud-arrow{animation:bt-cloud-drop 1.1s cubic-bezier(.45,0,.55,1) infinite} @keyframes bt-cloud-drop{0%{transform:translateY(-3px);opacity:0}35%{opacity:1}70%{transform:translateY(1px);opacity:1}100%{transform:translateY(2px);opacity:0}} .bt-update-dot{position:absolute;top:6px;right:6px;width:7px;height:7px;border-radius:50%;background:#E5484D;box-shadow:0 0 0 1.5px var(--bt-sidebar)} @@ -8679,7 +8684,7 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5491,18 +5491,140 @@ var SIDEBAR_CSS = ` +@@ -5491,18 +9976,140 @@ .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}} @@ -8825,7 +8830,7 @@ diff --git a/client.js b/client.js const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; -@@ -5512,19 +5512,19 @@ function useMemoryNotice(ids, useMemoryNews) { +@@ -5512,19 +10119,19 @@ return shown; } function MemoryNotice({ shown }) { @@ -8851,7 +8856,7 @@ diff --git a/client.js b/client.js const roster = useRoster((value) => value); const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? ""); const firstTurns = useSessions((list) => { -@@ -5537,7 +5537,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5537,7 +10144,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -8860,7 +8865,7 @@ diff --git a/client.js b/client.js if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); -@@ -5548,52 +5548,41 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe +@@ -5548,52 +10155,41 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -8910,14 +8915,15 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) +- ), +- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), +- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), +- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + bot ? (0, import_react18.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react18.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react18.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react18.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react18.createElement)(GoArrow)) - ), -- (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -- (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), -- (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ ), + (0, import_react18.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + (0, import_react18.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react18.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }), @@ -8932,7 +8938,7 @@ diff --git a/client.js b/client.js let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); -@@ -5642,7 +5642,7 @@ function useTranscriptView() { +@@ -5642,7 +10238,7 @@ }, []); return view; } @@ -8941,7 +8947,7 @@ diff --git a/client.js b/client.js const replies = useSessions((list) => { const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; -@@ -5658,18 +5658,22 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5658,18 +10254,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -8969,7 +8975,7 @@ diff --git a/client.js b/client.js const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); -@@ -5677,16 +5677,16 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5677,16 +10277,16 @@ }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); @@ -8990,7 +8996,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), -@@ -5694,7 +5694,7 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5694,7 +10294,7 @@ event.stopPropagation(); onDismiss(); } @@ -8999,7 +9005,7 @@ diff --git a/client.js b/client.js ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); -@@ -5703,9 +5703,9 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5703,9 +10303,9 @@ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -9012,7 +9018,7 @@ diff --git a/client.js b/client.js null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -@@ -5714,19 +5714,135 @@ function NewMessagePills({ sessionId, useSessions, useUi, useTurnClock }) { +@@ -5714,19 +10314,135 @@ ); } @@ -9156,7 +9162,7 @@ diff --git a/client.js b/client.js if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); -@@ -5745,10 +5745,10 @@ function useDictation(onText) { +@@ -5745,10 +10461,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -9170,7 +9176,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +5774,38 @@ function useDictation(onText) { +@@ -5774,19 +10490,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -9214,7 +9220,7 @@ diff --git a/client.js b/client.js const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]'); if (box instanceof HTMLElement) { box.focus(); -@@ -5797,10 +5797,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5797,10 +10532,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -9228,7 +9234,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), -@@ -5808,16 +5808,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { +@@ -5808,16 +10543,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -9249,7 +9255,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5825,17 @@ function menuIcons(roster) { +@@ -5825,17 +10560,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -9272,7 +9278,7 @@ diff --git a/client.js b/client.js if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); -@@ -5876,10 +5876,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5876,10 +10611,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -9286,7 +9292,7 @@ diff --git a/client.js b/client.js ref: button, type: "button", className: "bt-plus", -@@ -5889,8 +5889,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5889,8 +10624,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -9297,7 +9303,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +10635,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -9310,7 +9316,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,57 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +10657,57 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -9372,7 +9378,7 @@ diff --git a/client.js b/client.js function chatRows(roster, list, sessionId) { const here = conversationOf(roster, sessionId); if (here === null) return []; -@@ -5959,14 +5959,31 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5959,14 +10736,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -9408,7 +9414,7 @@ diff --git a/client.js b/client.js }, onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 }; -@@ -5995,6 +5995,83 @@ function atSources({ roster, sessions, launched, iconFor }) { +@@ -5995,6 +10789,83 @@ return [bots, chats]; } @@ -9492,7 +9498,7 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6008,8 +6008,8 @@ async function speechCatalog(speech) { +@@ -6008,8 +10879,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -9503,7 +9509,7 @@ diff --git a/client.js b/client.js }; ascii(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); -@@ -6030,9 +6030,9 @@ function waveBytes(samples, rate = 16e3) { +@@ -6030,9 +10901,9 @@ return bytes; } function base64Of(bytes) { @@ -9516,7 +9522,7 @@ diff --git a/client.js b/client.js } var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause }); async function openCapture() { -@@ -6121,233 +6121,9 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +@@ -6121,233 +10992,9 @@ }; } @@ -9752,7 +9758,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS = 120; function groupModels(models) { const groups = /* @__PURE__ */ new Map(); -@@ -6367,17 +6367,10 @@ function groupModels(models) { +@@ -6367,17 +11014,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -9772,7 +9778,7 @@ diff --git a/client.js b/client.js setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; -@@ -6402,12 +6402,12 @@ function effortRows(reasoning) { +@@ -6402,12 +11042,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -9790,7 +9796,7 @@ diff --git a/client.js b/client.js "button", { key: row.effort ?? "default", -@@ -6416,17 +6416,17 @@ function EffortRows({ reasoning, current, onPick }) { +@@ -6416,17 +11056,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -9814,7 +9820,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +11076,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -9843,7 +9849,7 @@ diff --git a/client.js b/client.js if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; -@@ -6484,6 +6484,7 @@ function useAnchoredMenu() { +@@ -6484,6 +11124,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -9851,7 +9857,7 @@ diff --git a/client.js b/client.js const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); const shown = modelOf(bot, roster); const current = bot.model ?? bot.appliedModel; -@@ -6511,10 +6511,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6511,10 +11152,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -9865,7 +9871,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6527,37 +6527,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo +@@ -6527,37 +11168,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -9918,7 +9924,7 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; -@@ -6569,13 +6569,13 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6569,13 +11211,13 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -9937,7 +9943,7 @@ diff --git a/client.js b/client.js key: model.ref, type: "button", className: "bt-model-chip", -@@ -6584,50 +6584,115 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { +@@ -6584,50 +11226,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -10069,7 +10075,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +11346,16 @@ }); } var MODEL_CSS = ` @@ -10086,7 +10092,7 @@ diff --git a/client.js b/client.js .bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease} .bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)} .bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px} -@@ -6675,18 +6675,96 @@ var MODEL_CSS = ` +@@ -6675,18 +11392,96 @@ @media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}} `; @@ -10191,7 +10197,7 @@ diff --git a/client.js b/client.js const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { -@@ -6719,35 +6719,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6719,35 +11514,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -10243,7 +10249,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,33 +6759,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,33 +11554,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -10293,7 +10299,7 @@ diff --git a/client.js b/client.js "button", { ref: button, -@@ -6798,19 +6798,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6798,19 +11593,19 @@ title: value, onClick: toggle }, @@ -10319,7 +10325,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +11625,8 @@ `; // src/client/schedules.js @@ -10330,7 +10336,7 @@ diff --git a/client.js b/client.js var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { -@@ -6839,8 +6839,8 @@ function publish(view) { +@@ -6839,8 +11634,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -10341,7 +10347,7 @@ diff --git a/client.js b/client.js listeners.add(setView); let stopped = false; let timer; -@@ -6848,7 +6848,7 @@ function useSchedules(actions) { +@@ -6848,7 +11643,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -10350,7 +10356,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +6873,8 @@ function weekdayNames(days) { +@@ -6873,7 +11668,8 @@ function everyText(seconds) { if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 }); if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 }); @@ -10360,7 +10366,7 @@ diff --git a/client.js b/client.js } function timingText(task) { const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : ""; -@@ -6887,8 +6887,8 @@ function timingText(task) { +@@ -6887,8 +11683,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -10371,7 +10377,7 @@ diff --git a/client.js b/client.js } case "cron": return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`; -@@ -6896,11 +6896,25 @@ function timingText(task) { +@@ -6896,11 +11692,25 @@ return ""; } } @@ -10402,7 +10408,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -6913,20 +6913,49 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6913,20 +11723,49 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -10461,7 +10467,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, -@@ -6934,7 +6934,7 @@ function TaskRow({ task, actions, onView, showBot, roster }) { +@@ -6934,7 +11773,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -10470,7 +10476,7 @@ diff --git a/client.js b/client.js ); } var pad = (number) => String(number).padStart(2, "0"); -@@ -6944,27 +6944,49 @@ function inAnHour() { +@@ -6944,27 +11783,49 @@ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`; } var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]]; @@ -10537,7 +10543,7 @@ diff --git a/client.js b/client.js setBusy(true); setError(""); let when3; -@@ -6975,151 +6975,298 @@ function TaskForm({ roster, actions, start, onDone, onCancel }) { +@@ -6975,151 +11836,298 @@ setBusy(false); return; } @@ -10898,7 +10904,7 @@ diff --git a/client.js b/client.js .bt-task[data-done]{opacity:.6} .bt-task[data-busy]{opacity:.4} .bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere} -@@ -7127,6 +7127,12 @@ var SCHEDULE_CSS = ` +@@ -7127,6 +12135,12 @@ .bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap} .bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)} .bt-task-remove svg{width:14px;height:14px} @@ -10911,7 +10917,7 @@ diff --git a/client.js b/client.js .bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135} .bt-task-head{display:flex;align-items:center;gap:6px} .bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out} -@@ -7158,12 +7158,12 @@ var SCHEDULE_CSS = ` +@@ -7158,12 +12172,12 @@ `; // src/client/capability-settings.js @@ -10927,7 +10933,7 @@ diff --git a/client.js b/client.js } catch { throw new Error(t("{label} must be valid JSON.", { label })); } -@@ -7172,11 +7172,11 @@ var parseMap = (text, label) => { +@@ -7172,11 +12186,11 @@ }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { @@ -10942,7 +10948,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, -@@ -7188,7 +7188,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl +@@ -7188,7 +12202,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -10951,7 +10957,7 @@ diff --git a/client.js b/client.js const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); -@@ -7199,7 +7199,7 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7199,7 +12213,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -10960,7 +10966,7 @@ diff --git a/client.js b/client.js payload.env = parseMap(draft.envText, t("Environment")); } else if (draft.kind === "mcp") { payload.transport = "streamable-http"; -@@ -7219,95 +7219,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { +@@ -7219,95 +12233,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -11096,7 +11102,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); -@@ -7330,20 +7330,20 @@ function CapabilitiesPage({ roster, actions, run }) { +@@ -7330,20 +12344,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -11124,7 +11130,7 @@ diff --git a/client.js b/client.js key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,10 +12373,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -11137,7 +11143,7 @@ diff --git a/client.js b/client.js key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7372,9 +12386,55 @@ ); } @@ -11195,7 +11201,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { +@@ -7384,13 +12444,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -11212,7 +11218,7 @@ diff --git a/client.js b/client.js if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); -@@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { +@@ -7404,16 +12464,16 @@ function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11235,7 +11241,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7428,45 @@ function ago(at, now) { +@@ -7428,45 +12488,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -11300,7 +11306,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +12538,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -11313,7 +11319,7 @@ diff --git a/client.js b/client.js if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); -@@ -7495,16 +7495,16 @@ function EntryRow({ entry, remove }) { +@@ -7495,16 +12555,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -11335,7 +11341,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, -@@ -7512,7 +7512,7 @@ function EntryRow({ entry, remove }) { +@@ -7512,7 +12572,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -11344,7 +11350,7 @@ diff --git a/client.js b/client.js ); } function Entries({ bot, view, remove }) { -@@ -7521,82 +7521,82 @@ function Entries({ bot, view, remove }) { +@@ -7521,82 +12581,82 @@ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; @@ -11462,7 +11468,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { +@@ -7606,9 +12666,9 @@ }, [botId]); return { view, error, load, show }; } @@ -11475,7 +11481,7 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); -@@ -7616,18 +7616,18 @@ function useNow(every) { +@@ -7616,18 +12676,18 @@ } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -11504,7 +11510,7 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7671,7 +7671,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7671,7 +12731,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -11513,7 +11519,7 @@ diff --git a/client.js b/client.js if (asked === "" || answer?.pending) return; setText(""); setAnswer({ asked, pending: true }); -@@ -7689,65 +7689,65 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7689,65 +12749,65 @@ const when3 = view?.summary ? ago(view.summary.at, now) : null; const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : ""; const busy = answer?.pending === true; @@ -11606,7 +11612,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7755,8 +12815,40 @@ ); } @@ -11648,7 +11654,7 @@ diff --git a/client.js b/client.js var capitalize = (word) => word[0].toUpperCase() + word.slice(1); async function avatarImage(file) { if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image")); -@@ -7780,8 +7780,8 @@ async function avatarImage(file) { +@@ -7780,8 +12872,8 @@ throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -11659,7 +11665,7 @@ diff --git a/client.js b/client.js const commit = () => { const next = draft?.trim(); setDraft(null); -@@ -7792,7 +7792,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7792,7 +12884,7 @@ if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -11668,7 +11674,7 @@ diff --git a/client.js b/client.js className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, -@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu +@@ -7813,29 +12905,29 @@ }); } function Row({ label, hint, stack = false, children }) { @@ -11707,7 +11713,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +12938,22 @@ return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -11735,7 +11741,7 @@ diff --git a/client.js b/client.js key: id, type: "button", className: "bt-shape", -@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7869,8 +12961,8 @@ title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -11746,7 +11752,7 @@ diff --git a/client.js b/client.js key: color, type: "button", className: "bt-color", -@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { +@@ -7878,19 +12970,19 @@ title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -11772,7 +11778,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); -@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7908,7 +13000,7 @@ window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -11781,7 +11787,7 @@ diff --git a/client.js b/client.js if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7922,32 +13014,32 @@ }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -11822,7 +11828,7 @@ diff --git a/client.js b/client.js "div", { className: "bt-dropzone", -@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7963,15 +13055,15 @@ upload(event.dataTransfer.files?.[0]); } }, @@ -11844,7 +11850,7 @@ diff --git a/client.js b/client.js const file = event.target.files?.[0]; event.target.value = ""; upload(file); -@@ -7982,26 +7982,27 @@ function AvatarEditor({ bot, actions, run, onClose }) { +@@ -7982,26 +13074,27 @@ function MemoryCard({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); @@ -11880,7 +11886,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,112 +8011,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +13104,116 @@ if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -12046,7 +12052,7 @@ diff --git a/client.js b/client.js ) ) ); -@@ -8130,9 +8130,17 @@ var BOT_SETTINGS_CSS = ` +@@ -8130,9 +13227,17 @@ .bt-bs .bt-avedit{left:0;transform:none} .bt-bs-row{min-height:48px} label.bt-bs-row{cursor:text} @@ -12065,7 +12071,7 @@ diff --git a/client.js b/client.js .bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease} .bt-bs-input:hover{background:var(--bt-hover)} .bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left} -@@ -8153,8 +8153,659 @@ label.bt-bs-row{cursor:text} +@@ -8153,8 +13258,659 @@ .bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)} .bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out} @media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}} @@ -12077,8 +12083,8 @@ diff --git a/client.js b/client.js + .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0} + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} -+`; -+ + `; + +// src/client/group-settings.js +var import_react29 = require("react"); +var ROOM_MODES = [ @@ -12521,8 +12527,8 @@ diff --git a/client.js b/client.js +.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out} +.bt-bs-stack .bt-seg{align-self:stretch} +@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}} - `; - ++`; ++ +// src/client/palette.js +var import_react30 = require("react"); +var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); @@ -12725,7 +12731,7 @@ diff --git a/client.js b/client.js // src/client/bot-draft.js function freshBotName(roster, base = "New Bot") { const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase())); -@@ -8165,36 +8165,48 @@ function freshBotName(roster, base = "New Bot") { +@@ -8165,36 +13921,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -12789,7 +12795,7 @@ diff --git a/client.js b/client.js if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); -@@ -8206,8 +8206,8 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8206,8 +13974,8 @@ setError(""); setBusy(true); try { @@ -12800,7 +12806,7 @@ diff --git a/client.js b/client.js } catch (failure) { setError(failure?.message ?? String(failure)); setBusy(false); -@@ -8219,25 +8219,25 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8219,25 +13987,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -12834,7 +12840,7 @@ diff --git a/client.js b/client.js look, hasPhoto: Boolean(image), onShape: (shape) => { -@@ -8249,27 +8249,27 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8249,27 +14017,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -12870,7 +12876,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,75 +8284,113 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,75 +14052,113 @@ } } }), @@ -13014,7 +13020,7 @@ diff --git a/client.js b/client.js const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); -@@ -8386,71 +8386,104 @@ function NewChat({ mode, roster, actions }) { +@@ -8386,71 +14192,104 @@ }; const created = (bot) => { close(); @@ -13139,7 +13145,7 @@ diff --git a/client.js b/client.js canGoBack: draft.fromList, onBack: draft.fromList ? () => { setDraft(null); -@@ -8467,33 +8467,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8467,33 +14306,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -13196,7 +13202,7 @@ diff --git a/client.js b/client.js ref: inputRef, autoFocus: true, value: query, -@@ -8517,20 +8517,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8517,20 +14364,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -13222,7 +13228,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +14389,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -13268,7 +13274,7 @@ diff --git a/client.js b/client.js rows: 1, value: message, placeholder, -@@ -8569,7 +8569,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8569,7 +14431,7 @@ } } }), @@ -13277,7 +13283,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +14439,41 @@ } // src/client/exchange.js @@ -13323,7 +13329,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,49 +8612,42 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,49 +14497,42 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -13387,7 +13393,7 @@ diff --git a/client.js b/client.js var VOICE_LABELS = { listening: "Listening", thinking: "Thinking", -@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { +@@ -8665,14 +14543,14 @@ }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -13405,7 +13411,7 @@ diff --git a/client.js b/client.js if (stopped) { media.getTracks().forEach((track) => track.stop()); return; -@@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8716,18 +14594,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -13435,7 +13441,7 @@ diff --git a/client.js b/client.js const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; -@@ -8735,24 +8735,24 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8735,24 +14613,24 @@ actions.closeVoice(); }; useEscape(close); @@ -13468,7 +13474,7 @@ diff --git a/client.js b/client.js if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { -@@ -8767,16 +8767,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8767,16 +14645,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -13488,7 +13494,7 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8789,9 +8789,9 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8789,9 +14667,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -13500,7 +13506,7 @@ diff --git a/client.js b/client.js else setPhase("listening"); } catch (error) { if (turn.current !== own) return; -@@ -8838,12 +8838,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8838,12 +14716,12 @@ setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -13517,7 +13523,7 @@ diff --git a/client.js b/client.js setSaid(spoken); setHeard(""); if (!window.speechSynthesis || !spoken) { -@@ -8858,7 +8858,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8858,7 +14736,7 @@ window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -13526,7 +13532,7 @@ diff --git a/client.js b/client.js const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening); const level = speech ? serviceLevel : micLevel; const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle"; -@@ -8875,291 +8875,663 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,291 +14753,663 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -14029,13 +14035,28 @@ diff --git a/client.js b/client.js - Row, - { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, - (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) -- ) ++ (0, import_react35.createElement)( ++ "section", ++ { className: "bt-set-section" }, ++ (0, import_react35.createElement)("h3", null, t("Yours")), ++ (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")), ++ (0, import_react35.createElement)( ++ "div", ++ { className: "bt-set-card" }, ++ yours.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-set-row" }, (0, import_react35.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react35.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) ++ ) + ) - ), - (0, import_react23.createElement)( - Card, - { title: t("Who answers") }, - (0, import_react23.createElement)( -- "div", ++ ) : null, ++ graph ? (0, import_react35.createElement)( ++ "div", ++ { className: "bt-wf-editor" }, ++ (0, import_react35.createElement)( + "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { - key: id, @@ -14054,7 +14075,42 @@ diff --git a/client.js b/client.js - Card, - { title: t("Working folder") }, - (0, import_react23.createElement)( -- "div", ++ { className: "bt-wf-actions" }, ++ (0, import_react35.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), ++ showUse ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, ++ opened?.source === "own" ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null ++ ), ++ (0, import_react35.createElement)("h3", { className: "bt-wf-title" }, graph.name), ++ graph.description ? (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, ++ (graph.nodes ?? []).length === 0 ? (0, import_react35.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react35.createElement)(GraphCanvas, { ++ graph, ++ roster, ++ onCommit, ++ width: canvasWidth, ++ editing: opened?.source === "own" && editing === true ++ }) ++ ) : null ++ ); ++} ++function RenameModal({ workflow, onClose, onConfirm }) { ++ const [name, setName] = (0, import_react35.useState)(workflow?.name ?? ""); ++ return (0, import_react35.createElement)( ++ "div", ++ { className: "bt-modal-mask", onClick: (event) => { ++ if (event.target === event.currentTarget) onClose?.(); ++ } }, ++ (0, import_react35.createElement)( ++ "div", ++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") }, ++ (0, import_react35.createElement)("h3", null, t("Rename workflow")), ++ (0, import_react35.createElement)( ++ "label", ++ { className: "bt-field" }, ++ t("Name"), ++ (0, import_react35.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true }) ++ ), ++ (0, import_react35.createElement)( + "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), - (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) @@ -14066,13 +14122,7 @@ diff --git a/client.js b/client.js - members.map((member) => { - const leads = room2.admin === member.id; - return (0, import_react23.createElement)( -+ (0, import_react35.createElement)( -+ "section", -+ { className: "bt-set-section" }, -+ (0, import_react35.createElement)("h3", null, t("Yours")), -+ (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")), -+ (0, import_react35.createElement)( - "div", +- "div", - { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react23.createElement)( - "button", @@ -14124,32 +14174,11 @@ diff --git a/client.js b/client.js - (0, import_react23.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) - ), - (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) -+ { className: "bt-set-card" }, -+ yours.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-set-row" }, (0, import_react35.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react35.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete })) -+ ) ++ { className: "bt-modal-actions" }, ++ (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), ++ (0, import_react35.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename")) ) -- ) -+ ) : null, -+ graph ? (0, import_react35.createElement)( -+ "div", -+ { className: "bt-wf-editor" }, -+ (0, import_react35.createElement)( -+ "div", -+ { className: "bt-wf-actions" }, -+ (0, import_react35.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")), -+ showUse ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null, -+ opened?.source === "own" ? (0, import_react35.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null -+ ), -+ (0, import_react35.createElement)("h3", { className: "bt-wf-title" }, graph.name), -+ graph.description ? (0, import_react35.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null, -+ (graph.nodes ?? []).length === 0 ? (0, import_react35.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react35.createElement)(GraphCanvas, { -+ graph, -+ roster, -+ onCommit, -+ width: canvasWidth, -+ editing: opened?.source === "own" && editing === true -+ }) -+ ) : null + ) ); } -function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { @@ -14181,63 +14210,41 @@ diff --git a/client.js b/client.js - onClick - }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); - return (0, import_react23.createElement)( -+function RenameModal({ workflow, onClose, onConfirm }) { -+ const [name, setName] = (0, import_react35.useState)(workflow?.name ?? ""); ++function DeleteModal({ workflow, onClose, onConfirm }) { + return (0, import_react35.createElement)( "div", - { className: "bt-set-card bt-ng" }, - (0, import_react23.createElement)( +- "div", +- { className: "bt-set-row bt-bs-row bt-bs-stack" }, +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), +- (0, import_react23.createElement)( + { className: "bt-modal-mask", onClick: (event) => { + if (event.target === event.currentTarget) onClose?.(); + } }, + (0, import_react35.createElement)( - "div", -- { className: "bt-set-row bt-bs-row bt-bs-stack" }, -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), -- (0, import_react23.createElement)( -+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") }, -+ (0, import_react35.createElement)("h3", null, t("Rename workflow")), -+ (0, import_react35.createElement)( -+ "label", -+ { className: "bt-field" }, -+ t("Name"), -+ (0, import_react35.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true }) -+ ), ++ "div", ++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") }, ++ (0, import_react35.createElement)("h3", null, t("Delete workflow")), ++ (0, import_react35.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })), + (0, import_react35.createElement)( "div", - { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, - roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) + { className: "bt-modal-actions" }, + (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), -+ (0, import_react35.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename")) ++ (0, import_react35.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete")) ) - ), - admin === null ? null : (0, import_react23.createElement)( -+ ) -+ ); -+} -+function DeleteModal({ workflow, onClose, onConfirm }) { -+ return (0, import_react35.createElement)( -+ "div", -+ { className: "bt-modal-mask", onClick: (event) => { -+ if (event.target === event.currentTarget) onClose?.(); -+ } }, -+ (0, import_react35.createElement)( - "div", +- "div", - { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), - (0, import_react23.createElement)( -+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") }, -+ (0, import_react35.createElement)("h3", null, t("Delete workflow")), -+ (0, import_react35.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })), -+ (0, import_react35.createElement)( - "div", +- "div", - { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, - roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) -+ { className: "bt-modal-actions" }, -+ (0, import_react35.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")), -+ (0, import_react35.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete")) - ) +- ) - ), - admin === null || members.length === 0 ? null : (0, import_react23.createElement)( - "label", @@ -14425,7 +14432,7 @@ diff --git a/client.js b/client.js // src/client/usage-data.js var HOUR = 0; -@@ -9277,13 +9277,13 @@ function summarize(view, range, keep = () => true, seriesOf = () => "all") { +@@ -9277,13 +15527,13 @@ } return result; } @@ -14443,7 +14450,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9294,11 @@ function percent(part, whole) { +@@ -9294,11 +15544,11 @@ } // src/client/usage-page.js @@ -14457,7 +14464,7 @@ diff --git a/client.js b/client.js var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id]; var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit]; var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"]; -@@ -9341,9 +9341,9 @@ function titleOf(range, bucket) { +@@ -9341,9 +15591,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -14470,7 +14477,7 @@ diff --git a/client.js b/client.js if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); -@@ -9363,9 +9363,9 @@ function useTween(value) { +@@ -9363,9 +15613,9 @@ }, [value]); return shown; } @@ -14482,7 +14489,7 @@ diff --git a/client.js b/client.js className: "bt-us-model", "aria-hidden": true, style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` } -@@ -9382,7 +9382,7 @@ function ownerFace(owner, roster) { +@@ -9382,7 +15632,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -14491,7 +14498,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +15642,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -14500,7 +14507,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +9411,33 @@ function kindLabel(kind, botFocus) { +@@ -9411,33 +15661,33 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -14545,7 +14552,7 @@ diff --git a/client.js b/client.js "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` -@@ -9455,83 +9455,83 @@ function niceTop(max) { +@@ -9455,83 +15705,83 @@ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max); } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { @@ -14657,7 +14664,7 @@ diff --git a/client.js b/client.js onClick ? "button" : "div", { className: "bt-us-row", -@@ -9540,69 +9540,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick +@@ -9540,69 +15790,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -14755,7 +14762,7 @@ diff --git a/client.js b/client.js const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; -@@ -9628,17 +9628,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9628,17 +15878,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -14776,7 +14783,7 @@ diff --git a/client.js b/client.js key, type: "button", className: "bt-us-day", -@@ -9654,15 +9654,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9654,15 +15904,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -14798,7 +14805,7 @@ diff --git a/client.js b/client.js ); }; const shortcuts = [ -@@ -9671,30 +9671,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { +@@ -9671,30 +15921,30 @@ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today] ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; @@ -14837,7 +14844,7 @@ diff --git a/client.js b/client.js const today = hourMs(`${view.now.slice(0, 10)} 00`); const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS; const from = dayKey(first); -@@ -9710,13 +9710,13 @@ function Heat({ view, keep, paint, index }) { +@@ -9710,13 +15960,13 @@ for (let week = 0; week < HEAT_WEEKS; week += 1) { const monday = first + week * 7 * DAY_MS; if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) { @@ -14853,7 +14860,7 @@ diff --git a/client.js b/client.js key: ms, className: "bt-us-cell", style: { "--w": week }, -@@ -9724,7 +9724,7 @@ function Heat({ view, keep, paint, index }) { +@@ -9724,7 +15974,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -14862,7 +14869,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +15983,61 @@ } : null; const active = [...daily.values()].filter((value) => value > 0).length; const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active }); @@ -14950,7 +14957,7 @@ diff --git a/client.js b/client.js let stopped = false; let timer; const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => { -@@ -9797,7 +9797,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9797,7 +16047,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -14959,7 +14966,7 @@ diff --git a/client.js b/client.js setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { -@@ -9814,7 +9814,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9814,7 +16064,7 @@ }, []); const today = view?.now.slice(0, 10); const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId); @@ -14968,7 +14975,7 @@ diff --git a/client.js b/client.js if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); -@@ -9823,7 +9823,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9823,7 +16073,7 @@ const models2 = view.models.map((model, at) => { const name = model.name || model.model || t("Unknown model"); const paint = modelPaint.get(at) ?? "var(--bt-us-rest)"; @@ -14977,7 +14984,7 @@ diff --git a/client.js b/client.js }); const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1; const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1; -@@ -9871,23 +9871,23 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9871,23 +16121,23 @@ }); return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 }; }, [view, rangeId, span, by, focusId, roster]); @@ -15009,7 +15016,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +16162,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -15018,7 +15025,7 @@ diff --git a/client.js b/client.js key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), -@@ -9924,7 +9924,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9924,7 +16174,7 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint), onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); @@ -15027,7 +15034,7 @@ diff --git a/client.js b/client.js key: `m${at}`, kind: "model", glyph: models[at].glyph(32), -@@ -9936,30 +9936,30 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9936,30 +16186,30 @@ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint), onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); @@ -15068,7 +15075,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +16218,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -15122,7 +15129,7 @@ diff --git a/client.js b/client.js body ); } -@@ -10150,33 +10150,216 @@ button.bt-us-row:hover{background:var(--bt-hover)} +@@ -10150,33 +16400,216 @@ @keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}} @keyframes bt-us-fade{from{opacity:0}} @keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}} @@ -15175,16 +15182,16 @@ diff --git a/client.js b/client.js { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react25.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) + (0, import_react37.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) -+); + ); +-var MARKS = { github: GitHubMark }; +-var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; +var FeishuMark = () => (0, import_react37.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + (0, import_react37.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), + (0, import_react37.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), + (0, import_react37.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) - ); --var MARKS = { github: GitHubMark }; --var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; ++); +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; +var ABOUT = { + github: () => t("Repositories, issues, and pull requests"), @@ -15350,7 +15357,7 @@ diff --git a/client.js b/client.js const run = (task, after) => { setBusy(true); setError(null); -@@ -10184,83 +10184,195 @@ function ConnectorRow({ connector, actions }) { +@@ -10184,83 +16617,195 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -15510,13 +15517,11 @@ diff --git a/client.js b/client.js setForm(false); setToken(""); + setValues({}); - setError(null); - } }, t("Cancel")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) ++ setError(null); ++ } }, t("Cancel")), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) - ) - ) : null, -- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ ) ++ ) : null, + on && gateway && picking ? (0, import_react37.createElement)( + "div", + { className: "bt-connector-form" }, @@ -15528,11 +15533,13 @@ diff --git a/client.js b/client.js + (0, import_react37.createElement)("span", { className: "bt-connector-link" }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + setPicking(false); -+ setError(null); -+ } }, t("Cancel")), + setError(null); + } }, t("Cancel")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) -+ ) -+ ) : null, + ) + ) : null, +- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + error ? (0, import_react37.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } @@ -15576,7 +15583,7 @@ diff --git a/client.js b/client.js const date = new Date(time); const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString(); const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" }); -@@ -10270,18 +10270,18 @@ function entrySummary(entry) { +@@ -10270,18 +16815,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -15602,7 +15609,7 @@ diff --git a/client.js b/client.js void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { -@@ -10289,7 +10289,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10289,7 +16834,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -15611,7 +15618,7 @@ diff --git a/client.js b/client.js const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; -@@ -10307,8 +10307,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10307,8 +16852,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -15622,7 +15629,7 @@ diff --git a/client.js b/client.js setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it.")); })); const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log.")))); -@@ -10317,59 +10317,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10317,59 +16862,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -15710,7 +15717,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +16922,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -15721,7 +15728,7 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +16934,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -15743,7 +15750,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +10416,56 @@ var FEEDBACK_CSS = ` +@@ -10416,47 +16962,56 @@ .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -15814,7 +15821,7 @@ diff --git a/client.js b/client.js let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); -@@ -10466,87 +10466,72 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { +@@ -10466,87 +17021,72 @@ live = false; }; }, []); @@ -15925,7 +15932,7 @@ diff --git a/client.js b/client.js .bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} .bt-about-name{font-size:16px;line-height:22px;font-weight:600} .bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -@@ -10569,54 +10569,59 @@ body:not([data-bt-motion=quiet]) .bt-about-mark:hover .bt-mark[data-live][data-s +@@ -10569,54 +17109,59 @@ `; // src/client/settings.js @@ -16003,7 +16010,7 @@ diff --git a/client.js b/client.js key: ink, type: "button", className: "bt-swatch", -@@ -10626,75 +10626,86 @@ function GeneralPage({ roster, actions, run }) { +@@ -10626,75 +17171,86 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -16113,7 +16120,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": motion === id, -@@ -10706,69 +10706,69 @@ function MotionSection({ roster, actions, run }) { +@@ -10706,69 +17262,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -16213,7 +16220,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10779,250 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +17335,250 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -16505,7 +16512,7 @@ diff --git a/client.js b/client.js const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; -@@ -10884,13 +10884,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10884,13 +17586,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -16530,7 +16537,7 @@ diff --git a/client.js b/client.js bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null, -@@ -10901,20 +10901,22 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10901,20 +17612,22 @@ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null, bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null ], @@ -16559,7 +16566,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,14 +17636,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16578,7 +16585,7 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10952,14 +17665,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -16598,7 +16605,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10968,16 +10968,31 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -10968,16 +17681,31 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -16631,7 +16638,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11038,244 +11038,8 @@ function migrateStorage(storage = globalThis.localStorage) { +@@ -11038,244 +17766,8 @@ } } @@ -16877,7 +16884,7 @@ diff --git a/client.js b/client.js var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +17781,10 @@ var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { @@ -16890,7 +16897,7 @@ diff --git a/client.js b/client.js if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; -@@ -11302,14 +11302,14 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11302,14 +17794,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -16909,7 +16916,7 @@ diff --git a/client.js b/client.js key: option.value, type: "button", "aria-pressed": option.value === current, -@@ -11323,42 +11323,42 @@ function PermissionRow({ bot, roster, actions, run }) { +@@ -11323,42 +17815,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -16967,7 +16974,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,195 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +17859,195 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -16999,11 +17006,8 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + return (0, import_react42.createElement)( - "div", - null, -- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), -- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ "div", ++ null, + (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2), + long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null @@ -17056,8 +17060,11 @@ diff --git a/client.js b/client.js + const people2 = facePeople(room2, roster); + const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); + return (0, import_react42.createElement)( -+ "div", -+ null, + "div", + null, +- (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), +- (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), +- long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + (0, import_react42.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), + (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), + (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react42.createElement)(FacePersonRow, { @@ -17208,7 +17215,7 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; -@@ -11457,7 +11457,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11457,7 +18057,7 @@ const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -17217,7 +17224,7 @@ diff --git a/client.js b/client.js const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -11467,41 +11467,67 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11467,41 +18067,67 @@ return false; }); const share = () => { @@ -17301,7 +17308,7 @@ diff --git a/client.js b/client.js ]; } const current = bot?.name ?? room2.name; -@@ -11515,12 +11515,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11515,12 +18141,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -17318,7 +17325,7 @@ diff --git a/client.js b/client.js className: "bt-drawer-name-input", autoFocus: true, value: draftName, -@@ -11535,98 +11535,97 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a +@@ -11535,98 +18161,97 @@ setEditing(false); } } @@ -17462,7 +17469,7 @@ diff --git a/client.js b/client.js var room = { rule: null, selector: "" }; var roomRule = () => { const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected; -@@ -11674,11 +11674,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref +@@ -11674,11 +18299,11 @@ var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab"); function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) { const pages = useBrowserPages((map) => map); @@ -17479,7 +17486,7 @@ diff --git a/client.js b/client.js const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; -@@ -11707,7 +11707,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11707,7 +18332,7 @@ trail.cancel(); }; }); @@ -17488,7 +17495,7 @@ diff --git a/client.js b/client.js const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { -@@ -11721,13 +11721,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11721,13 +18346,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -17505,7 +17512,7 @@ diff --git a/client.js b/client.js "button", { type: "button", -@@ -11737,13 +11737,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11737,13 +18362,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -17522,7 +17529,7 @@ diff --git a/client.js b/client.js "button", { key: tabId, -@@ -11764,9 +11764,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11764,9 +18389,9 @@ } } }, @@ -17535,7 +17542,7 @@ diff --git a/client.js b/client.js className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), -@@ -11778,20 +11778,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, +@@ -11778,20 +18403,20 @@ }, "×") ); }), @@ -17563,7 +17570,7 @@ diff --git a/client.js b/client.js const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); -@@ -11817,43 +11817,43 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11817,43 +18442,43 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -17620,7 +17627,7 @@ diff --git a/client.js b/client.js ref: input, type: "text", spellCheck: false, -@@ -11877,25 +11877,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) +@@ -11877,25 +18502,25 @@ } }) ), @@ -17656,7 +17663,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +18532,41 @@ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2); const active = tabState.active; const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE); @@ -17714,7 +17721,7 @@ diff --git a/client.js b/client.js let timer; const onResize = () => { setResizing(true); -@@ -11955,28 +11955,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11955,28 +18580,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -17750,7 +17757,7 @@ diff --git a/client.js b/client.js if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); -@@ -12013,7 +12013,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12013,7 +18638,7 @@ return actions[name](...args); }; } @@ -17759,7 +17766,7 @@ diff --git a/client.js b/client.js const move = fly.current; if (!move) return; fly.current = null; -@@ -12096,7 +12096,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12096,7 +18721,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -17768,7 +17775,7 @@ diff --git a/client.js b/client.js "aside", { ref: root, -@@ -12113,15 +12113,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -12113,15 +18738,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -17789,7 +17796,7 @@ diff --git a/client.js b/client.js sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, -@@ -12217,9 +12217,79 @@ var BROWSER_CSS = ` +@@ -12217,9 +18842,79 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -17871,7 +17878,7 @@ diff --git a/client.js b/client.js var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) { -@@ -12227,15 +12227,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12227,15 +18922,15 @@ const ui2 = useUi((value) => value); useRegistry((value) => value); const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); @@ -17891,7 +17898,7 @@ diff --git a/client.js b/client.js if (open) { setKept(open); return void 0; -@@ -12245,19 +12245,26 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity +@@ -12245,19 +18940,26 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -17928,7 +17935,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +12294,19 @@ function readPage(limits) { +@@ -12294,19 +18996,19 @@ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase(); return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`); }; @@ -17955,7 +17962,7 @@ diff --git a/client.js b/client.js const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el); const childrenOf = (el) => { if (el.shadowRoot) return el.shadowRoot.childNodes; -@@ -12340,8 +12340,8 @@ function readPage(limits) { +@@ -12340,8 +19042,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -17966,7 +17973,7 @@ diff --git a/client.js b/client.js ).slice(0, 80); const fenced = (code, lang) => { const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length)); -@@ -12372,7 +12372,7 @@ ${fence}`; +@@ -12372,7 +19074,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -17975,7 +17982,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} +@@ -12398,11 +19100,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -17989,7 +17996,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +12410,18 @@ ${children(cell, ctx)} +@@ -12410,18 +19112,18 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -18013,7 +18020,7 @@ diff --git a/client.js b/client.js } width = Math.min(width, limits.tableCols); if (grid.length === 0 || width === 0) return caption ? ` -@@ -12480,9 +12480,9 @@ ${body2 || "(no text)"} +@@ -12480,9 +19182,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -18026,7 +18033,7 @@ diff --git a/client.js b/client.js } if (node.nodeType !== 1) return ""; const el = node; -@@ -12552,7 +12552,7 @@ ${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark} +@@ -12552,7 +19254,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -18035,7 +18042,7 @@ diff --git a/client.js b/client.js } const end = body2.trimEnd().length; const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2; -@@ -12570,12 +12570,12 @@ ${marked} +@@ -12570,12 +19272,12 @@ return marked; } const body = document.body; @@ -18052,7 +18059,7 @@ diff --git a/client.js b/client.js stop ||= `the text passed ${limits.chars} characters`; } const page = { -@@ -12585,7 +12585,7 @@ ${marked} +@@ -12585,7 +19287,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -18061,7 +18068,7 @@ diff --git a/client.js b/client.js items, stats: { ...stats, ms: Math.round(performance.now() - started) } }; -@@ -12613,15 +12613,15 @@ var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.in +@@ -12613,15 +19315,15 @@ var BLOCKED = "Cloud metadata addresses do not open here."; var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname); function browserAddress(input) { @@ -18085,7 +18092,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +13052,430 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +19754,509 @@ }; } @@ -18211,6 +18218,14 @@ diff --git a/client.js b/client.js +.bt-project-missing{font-size:11px;opacity:.6} +.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} +.bt-project-actions{display:flex;gap:8px;margin:0 0 14px} ++.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} ++.bt-project-counts b{font-weight:600} ++.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} ++.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} ++.bt-project-state{font-size:11px;margin-left:auto} ++.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} ++.bt-project-reason{font-size:11px;opacity:.7} +`; +var PROJECT_SOURCES = ["bot", "group", "workflow"]; +var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" }; @@ -18279,7 +18294,7 @@ diff --git a/client.js b/client.js + } + }; +} -+function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { ++function projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues } = {}) { + const list = Array.isArray(rows) ? rows.filter((row) => row !== null && typeof row === "object" && text(row.id) !== "") : []; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); + const groups = PROJECT_SOURCES.map((source) => ({ @@ -18301,13 +18316,31 @@ diff --git a/client.js b/client.js + }; + }) + })).filter((group) => group.rows.length > 0); ++ const countOf = (value) => value === void 0 || value === null ? null : value; ++ const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => { ++ const id = text(item.id); ++ const source = text(list.find((row) => text(row.id) === id)?.source); ++ const task = byId.get(id); ++ return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; ++ }); ++ const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); ++ const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); ++ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null })); ++ const queueGroups = [ ++ { group: "waiting", label: "Waiting", items: waiting }, ++ { group: "inflight", label: "In flight", items: inflight }, ++ { group: "blocked", label: "Blocked", items: blocked } ++ ].filter((entry) => entry.items.length > 0); ++ const rawCounts = queues?.counts ?? null; + return { + projectId: text(project?.id) || null, + title: text(project?.name) || "Project", + canManage: canManage === true, + groups, ++ queues: queueGroups, ++ counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, + count: list.length, -+ empty: list.length === 0, ++ empty: list.length === 0 && queueGroups.length === 0, + emptyText: "No cards yet.", + record: projectBoardRecord({ decisions, meetings }) + }; @@ -18350,6 +18383,47 @@ diff --git a/client.js b/client.js + }) + )); +} ++function waitedLabel(ms) { ++ if (!Number.isFinite(ms) || ms < 0) return null; ++ const minutes = Math.floor(ms / 6e4); ++ if (minutes < 60) return `${minutes}m`; ++ const hours = Math.floor(minutes / 60); ++ if (hours < 24) return `${hours}h`; ++ return `${Math.floor(hours / 24)}d`; ++} ++function ProjectQueueGroups({ view, onOpen }) { ++ return view.queues.map((entry) => (0, import_react46.createElement)( ++ "section", ++ { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group }, ++ (0, import_react46.createElement)("h4", null, t(entry.label)), ++ entry.items.map((row) => { ++ const open = row.board === null ? void 0 : () => onOpen?.(row.board); ++ const note = entry.group === "waiting" ? (0, import_react46.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react46.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react46.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react46.createElement)("span", { className: "bt-project-reason" }, row.reason); ++ return (0, import_react46.createElement)( ++ "div", ++ { ++ key: row.key, ++ className: "bt-project-row", ++ "data-task-id": row.id, ++ "data-state": row.state ?? void 0, ++ role: open === void 0 ? void 0 : "button", ++ tabIndex: open === void 0 ? void 0 : 0, ++ onClick: open, ++ onKeyDown: open === void 0 ? void 0 : (event) => { ++ if (event.key === "Enter" || event.key === " ") open(); ++ } ++ }, ++ (0, import_react46.createElement)( ++ "span", ++ { className: "bt-project-title-wrap" }, ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`) ++ ), ++ note ++ ); ++ }) ++ )); ++} ++var countText = (value) => value === null ? t("unknown") : String(value); +function ProjectBoardRecord({ record }) { + const { decisions, meetings } = record; + const decisionRows = decisions.files.map((file) => (0, import_react46.createElement)( @@ -18387,15 +18461,25 @@ diff --git a/client.js b/client.js + meetings.pending.length === 0 ? null : pendingRows + ); +} -+function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { -+ const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }); ++function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { ++ const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }); + return (0, import_react46.createElement)( + "div", + { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" }, + (0, import_react46.createElement)("h3", null, view.title), ++ // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. ++ view.counts === null ? null : (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" }, ++ (0, import_react46.createElement)("span", null, [t("Waiting"), ": ", (0, import_react46.createElement)("b", { key: "w" }, countText(view.counts.waiting))]), ++ (0, import_react46.createElement)("span", null, [t("In flight"), ": ", (0, import_react46.createElement)("b", { key: "f" }, countText(view.counts.inflight))]), ++ (0, import_react46.createElement)("span", null, [t("Blocked"), ": ", (0, import_react46.createElement)("b", { key: "b" }, countText(view.counts.blocked))]), ++ (0, import_react46.createElement)("span", null, [t("Done"), ": ", (0, import_react46.createElement)("b", { key: "d" }, countText(view.counts.done))]) ++ ), + // Management affordances are drawn only for the owner; the refusal itself is the host's. + view.canManage ? (0, import_react46.createElement)("div", { className: "bt-project-actions" }, (0, import_react46.createElement)("button", { type: "button" }, t("Project settings")), (0, import_react46.createElement)("button", { type: "button" }, t("Members"))) : null, + (0, import_react46.createElement)(ProjectBoardRows, { view, onOpen }), ++ (0, import_react46.createElement)(ProjectQueueGroups, { view, onOpen }), + (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) + ); +} @@ -18437,7 +18521,8 @@ diff --git a/client.js b/client.js + read("tasks", {}) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; -+ if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); ++ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; ++ if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues }); + } catch { + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } @@ -18479,6 +18564,7 @@ diff --git a/client.js b/client.js + tasks: state.tasks, + decisions: state.decisions, + meetings: state.meetings, ++ queues: state.queues, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { @@ -18517,7 +18603,7 @@ diff --git a/client.js b/client.js function disposeAll(disposers, label) { for (const dispose of [...disposers].reverse()) { try { -@@ -13075,8 +13075,8 @@ function surfaceGroups(install) { +@@ -13075,8 +20275,8 @@ }; return { reconcile(wanted) { @@ -18528,7 +18614,7 @@ diff --git a/client.js b/client.js if (!wanted[group] || mounted[group].length > 0) continue; try { mounted[group] = install[group](); -@@ -13086,7 +13086,7 @@ function surfaceGroups(install) { +@@ -13086,7 +20286,7 @@ } }, dispose() { @@ -18537,7 +18623,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +20305,7 @@ } // src/client/user-message.js @@ -18546,7 +18632,7 @@ diff --git a/client.js b/client.js var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { -@@ -13119,87 +13119,165 @@ function contentParts(content) { +@@ -13119,87 +20319,165 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -18595,7 +18681,8 @@ diff --git a/client.js b/client.js + return faceAliases(record, seat.bot).map((name) => ({ ...target, name, mentionLabel })); + }); + return bots.concat(humans); -+} + } +-function userTextParts(text, bots, referenceLabels = []) { +function messageAuthor(data, roster, sessionId) { + const source = data?.source ?? {}; + const authorId = source.authorId; @@ -18610,8 +18697,7 @@ diff --git a/client.js b/client.js + ownerName: remote?.ownerName, + mine: roster?.self?.memberId === authorId + }; - } --function userTextParts(text, bots, referenceLabels = []) { ++} +function userTextParts(text2, bots, referenceLabels = []) { const parts = []; let cursor = 0; @@ -18746,7 +18832,7 @@ diff --git a/client.js b/client.js .bt-ubody a{color:inherit} .bt-umention{display:inline-flex;align-items:center;gap:3px;vertical-align:-3px;max-width:100%;padding:0 1px;border:0;background:none;color:inherit;font:inherit;font-weight:600;line-height:inherit;cursor:pointer;border-radius:6px;white-space:nowrap} .bt-umention:hover{text-decoration:underline;text-underline-offset:2px} -@@ -13216,42 +13216,115 @@ var USER_MESSAGE_CSS = ` +@@ -13216,42 +20494,115 @@ `; // src/client/approval-notices.js @@ -18875,7 +18961,7 @@ diff --git a/client.js b/client.js migrateStorage(); const UNREAD_KEY = STORAGE.unread; let savedUnread = {}; -@@ -13274,7 +13274,13 @@ function apply(ctx) { +@@ -13274,7 +20625,13 @@ } catch { seenAt = {}; } @@ -18890,7 +18976,7 @@ diff --git a/client.js b/client.js const surface = createSource({ mode: readMode(), plain: false }); let agentSessionCache = /* @__PURE__ */ new Set(); try { -@@ -13308,6 +13308,8 @@ function apply(ctx) { +@@ -13308,6 +20665,8 @@ return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -18899,7 +18985,7 @@ diff --git a/client.js b/client.js const work = createSource({ bots: {}, approvals: [] }); let workKey = ""; const live = createSource({}); -@@ -13344,13 +13344,25 @@ function apply(ctx) { +@@ -13344,13 +20703,25 @@ } return { ...state, unread }; }); @@ -18926,7 +19012,7 @@ diff --git a/client.js b/client.js if (order < applied) return; applied = order; const nextActivity = JSON.stringify(value.activity ?? {}); -@@ -13358,6 +13358,11 @@ function apply(ctx) { +@@ -13358,6 +20729,11 @@ activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -18938,7 +19024,7 @@ diff --git a/client.js b/client.js const nextWork = JSON.stringify(value.work ?? null); if (nextWork !== workKey) { workKey = nextWork; -@@ -13368,10 +13368,15 @@ function apply(ctx) { +@@ -13368,10 +20744,15 @@ const live2 = {}; if (value.connectors !== void 0 && JSON.stringify(value.connectors) !== JSON.stringify(current.connectors ?? [])) live2.connectors = value.connectors; if (value.capabilityStatuses !== void 0 && JSON.stringify(value.capabilityStatuses) !== JSON.stringify(current.capabilityStatuses ?? {})) live2.capabilityStatuses = value.capabilityStatuses; @@ -18954,7 +19040,7 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13488,20 +13488,12 @@ function apply(ctx) { +@@ -13488,20 +20869,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -18977,7 +19063,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13520,7 @@ function apply(ctx) { +@@ -13520,7 +20893,7 @@ const accent = isHex(prefs.accent) ? prefs.accent : void 0; const motion = prefs.motion === "quiet" || prefs.motion === "lively" ? prefs.motion : "normal"; document.body.dataset.btMotion = motion; @@ -18986,7 +19072,7 @@ diff --git a/client.js b/client.js if (next === key) return; key = next; style.textContent = themeCss(themes[id], accent); -@@ -13536,7 +13536,7 @@ function apply(ctx) { +@@ -13536,7 +20909,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -18995,7 +19081,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13588,7 @@ function apply(ctx) { +@@ -13588,6 +20961,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -19003,7 +19089,7 @@ diff --git a/client.js b/client.js if (isAgentSessionId(id)) return actions.openAgentSession(id); ui2.set((state) => state.newChat || state.exchange || state.menu || state.palette ? { ...state, newChat: null, exchange: null, menu: null, palette: null } : state); setPlain(false); -@@ -13653,12 +13653,12 @@ function apply(ctx) { +@@ -13653,12 +21027,12 @@ }, // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. @@ -19019,7 +19105,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13708,15 @@ function apply(ctx) { +@@ -13708,10 +21082,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -19037,7 +19123,7 @@ diff --git a/client.js b/client.js }, openMemory: (botId) => { ui2.set((state) => ({ ...state, memory: botId, menu: null, palette: null })); -@@ -13719,6 +13719,19 @@ function apply(ctx) { +@@ -13719,6 +21098,19 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -19057,7 +19143,7 @@ diff --git a/client.js b/client.js openConnectors: () => { ui2.set((state) => ({ ...state, settings: { page: "connectors", id: null }, menu: null, palette: null, newChat: null })); }, -@@ -13728,7 +13728,7 @@ function apply(ctx) { +@@ -13728,7 +21120,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -19066,7 +19152,7 @@ diff --git a/client.js b/client.js } writeMode(mode); surface.set((state) => ({ ...state, mode })); -@@ -13757,6 +13757,12 @@ function apply(ctx) { +@@ -13757,6 +21149,12 @@ const seen = seenAt[seenKey(id)] ?? (made?.createdBy && Number.isFinite(made.createdAt) ? made.createdAt : null); ui2.set((state) => ({ ...state, visits: { ...state.visits, [id]: { seen, entered: Date.now() } } })); }, @@ -19079,7 +19165,7 @@ diff --git a/client.js b/client.js endVisit: (id) => { leaveVisits([id]); ui2.set((state) => { -@@ -13780,7 +13780,7 @@ function apply(ctx) { +@@ -13780,7 +21178,7 @@ } return { ...state, reactions }; }), @@ -19088,7 +19174,7 @@ diff --git a/client.js b/client.js clearQuote: (token) => ui2.set((state) => state.quote?.token === token ? { ...state, quote: null } : state), clearUnread: (id) => setUnread(id, false), setFlags: (id, flags) => after(call("set-flags", { id, ...flags })), -@@ -13808,10 +13808,16 @@ function apply(ctx) { +@@ -13808,10 +21206,16 @@ setPrefs: (prefs) => after(call("set-prefs", prefs)), setMain: (id, main = true, options = {}) => after(call("set-main", { id, main, ...options })), updateRoom: (id, patch) => after(call("update-room", { id, ...patch })), @@ -19106,7 +19192,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,31 @@ function apply(ctx) { +@@ -13819,15 +21223,31 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -19139,7 +19225,7 @@ diff --git a/client.js b/client.js scheduleDelete: (sessionId, id) => call("schedule-delete", { sessionId, id }), exchange: (a, b) => call("exchange", { a, b }).catch(() => []), roomProgress: (roomId) => call("room-progress", { roomId }).catch(() => []), -@@ -13836,7 +13836,7 @@ function apply(ctx) { +@@ -13836,7 +21256,7 @@ usage: () => call("usage", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }), // `summarize` starts a summary when the entries changed; `regenerate` always does. memoryView: (botId, flags = {}) => call("memory-view", { botId, ...flags }), @@ -19148,7 +19234,7 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -13846,6 +13846,8 @@ function apply(ctx) { +@@ -13846,6 +21266,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -19157,7 +19243,7 @@ diff --git a/client.js b/client.js dismissToast: (token) => ui2.set((state) => state.toast && (token === void 0 || state.toast.token === token) ? { ...state, toast: null } : state) }; if (browsers) { -@@ -13890,6 +13890,7 @@ function apply(ctx) { +@@ -13890,6 +21312,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -19165,7 +19251,7 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); -@@ -13922,6 +13922,9 @@ function apply(ctx) { +@@ -13922,6 +21345,9 @@ wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -19175,7 +19261,7 @@ diff --git a/client.js b/client.js actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { -@@ -13964,7 +13964,13 @@ function apply(ctx) { +@@ -13964,7 +21390,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -19190,7 +19276,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13989,17 @@ function apply(ctx) { +@@ -13989,7 +21421,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -19208,7 +19294,7 @@ diff --git a/client.js b/client.js const installAtMenu = () => { const fiber = ctx.inject(["inputTriggers"], (scope) => { scope.effect(() => { -@@ -14001,7 +14001,12 @@ function apply(ctx) { +@@ -14001,7 +21443,12 @@ const launched = (sessionId) => controllerOf(sessionId)?.launcher.getSnapshot() === CHATS_SOURCE; const offs = atSources({ roster, sessions: ctx.sessions, launched, iconFor: menuIcons(roster) }).map((source) => triggers.registerSource(source)); atMenu = { controllerOf }; @@ -19221,7 +19307,7 @@ diff --git a/client.js b/client.js atMenu = null; for (const off of offs) off(); }; -@@ -14010,6 +14010,14 @@ function apply(ctx) { +@@ -14010,6 +21457,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -19236,7 +19322,7 @@ diff --git a/client.js b/client.js actions.referenceChat = (sessionId, span) => { const controller = atMenu?.controllerOf(sessionId); if (controller === void 0 || span === void 0) return; -@@ -14019,33 +14019,49 @@ function apply(ctx) { +@@ -14019,33 +21474,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -19290,7 +19376,7 @@ diff --git a/client.js b/client.js shadow("conversation.chat.node", { key: "assistant-step", priority: -10, locale: "chat", inject: assistantFace }, AssistantCell), shadow("conversation.chat.node", { key: "turn-trigger", priority: -10, locale: "chat", inject: face }, TriggerCell), shadow("conversation.chat.node", { key: "tool-call", priority: -10, locale: "chat", inject: face }, ToolCell), -@@ -14057,15 +14057,24 @@ function apply(ctx) { +@@ -14057,15 +21528,24 @@ // every message. shadow("conversation.chat.node", { key: "question-reply", priority: -10, locale: "chat" }, AnsweredCell), shadow("conversation.chat.node", { key: "compaction", priority: -10, locale: "chat" }, Nothing), @@ -19318,7 +19404,7 @@ diff --git a/client.js b/client.js shadow("conversation.input.right", { id: "bot-voice", order: 100, inject: face }, ComposerVoice), shadow("conversation.composer", { priority: -10, -@@ -14074,8 +14074,17 @@ function apply(ctx) { +@@ -14074,9 +21554,18 @@ shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), @@ -19326,7 +19412,7 @@ diff --git a/client.js b/client.js + shadow("conversation.input.dock", { id: "bot-secret", order: -90, inject: face }, SecretDock), + shadow("conversation.input.dock", { id: "flybot-session", order: -80, inject: face }, FlyBotSessionDock) ]; -+ ctx.effect(() => { + ctx.effect(() => { + const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); + return () => { @@ -19334,6 +19420,7 @@ diff --git a/client.js b/client.js + style.remove(); + }; + }, "ds-bot: dead turn lines"); - ctx.effect(() => { ++ ctx.effect(() => { const style = installStyles(RETURN_CSS, "return"); return () => style.remove(); + }, "ds-bot: return styles"); diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index e371d15f9..7f31a6f6a 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -34,6 +34,14 @@ export const PROJECT_BOARD_CSS = ` .bt-project-missing{font-size:11px;opacity:.6} .bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto} .bt-project-actions{display:flex;gap:8px;margin:0 0 14px} +.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8} +.bt-project-counts b{font-weight:600} +.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default} +.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto} +.bt-project-state{font-size:11px;margin-left:auto} +.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b} +.bt-project-reason{font-size:11px;opacity:.7} ` // Sources in the order the board reads them; a source with no rows is left out so three empty @@ -124,7 +132,9 @@ export function projectBoardRecord({ decisions, meetings } = {}) { // `project-read` and only decides what the caller may *ask* for — the host refuses regardless. // `tasks` is the one task read joined by id: the row carries an id and who holds it, not its text. // `decisions` and `meetings` are the host's read of the Project's record (#1767), passed through. -export function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { +// `queues` is the other half of that same read (#1768): the waiting, in-flight and blocked cards +// and their counts, as the host answered them. +export function projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues } = {}) { const list = Array.isArray(rows) ? rows.filter(row => row !== null && typeof row === 'object' && text(row.id) !== '') : [] const byId = new Map((Array.isArray(tasks) ? tasks : []) .filter(task => task !== null && typeof task === 'object' && text(task.id) !== '') @@ -152,13 +162,36 @@ export function projectBoardView({ project, rows, canManage, tasks, decisions, m }), })) .filter(group => group.rows.length > 0) + // The three groups beside the columns (#1768). They are the host's own read of the queue, the + // leases and the blocked cards; nothing is recomputed here, and a count the host could not read + // stays `null` so the header says "unknown" instead of a zero nobody measured. + const countOf = (value) => (value === undefined || value === null ? null : value) + const queueItems = (items, annotate) => (Array.isArray(items) ? items : []) + .filter(item => item !== null && typeof item === 'object' && text(item.id) !== '') + .map((item) => { + const id = text(item.id) + const source = text(list.find(row => text(row.id) === id)?.source) + const task = byId.get(id) + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) } + }) + const waiting = queueItems(queues?.waiting, item => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })) + const inflight = queueItems(queues?.inflight, item => ({ holder: text(item.holder) || null, state: item.stalled === true ? 'stalled' : 'running' })) + const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null })) + const queueGroups = [ + { group: 'waiting', label: 'Waiting', items: waiting }, + { group: 'inflight', label: 'In flight', items: inflight }, + { group: 'blocked', label: 'Blocked', items: blocked }, + ].filter(entry => entry.items.length > 0) + const rawCounts = queues?.counts ?? null return { projectId: text(project?.id) || null, title: text(project?.name) || 'Project', canManage: canManage === true, groups, + queues: queueGroups, + counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) }, count: list.length, - empty: list.length === 0, + empty: list.length === 0 && queueGroups.length === 0, emptyText: 'No cards yet.', record: projectBoardRecord({ decisions, meetings }), } @@ -193,6 +226,49 @@ function ProjectBoardRows({ view, onOpen }) { }))) } +// How long a card has waited, in the unit a reader wants: minutes under an hour, hours under a +// day. A card with no `createdAt` says nothing rather than "0m". +export function waitedLabel(ms) { + if (!Number.isFinite(ms) || ms < 0) return null + const minutes = Math.floor(ms / 60000) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + return `${Math.floor(hours / 24)}d` +} + +// The three groups beside the columns (#1768): a role queue with its wait, the card a claim holds +// with its holder and lease state, and a blocked card with the reason it already stores. +function ProjectQueueGroups({ view, onOpen }) { + return view.queues.map(entry => h('section', { key: `queue:${entry.group}`, className: 'bt-project-group', 'data-queue': entry.group }, + h('h4', null, t(entry.label)), + entry.items.map(row => { + const open = row.board === null ? undefined : () => onOpen?.(row.board) + // The annotation is the group's own: a role and how long it waited, the holder and whether + // the lease is live, or the reason the card is blocked. Never recomputed from the card here. + const note = entry.group === 'waiting' + ? h('span', { className: 'bt-project-wait' }, [row.role === null ? null : h('span', { key: 'role', className: 'bt-project-role' }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) + : entry.group === 'inflight' + ? h('span', { className: 'bt-project-state' }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) + : h('span', { className: 'bt-project-reason' }, row.reason) + return h('div', { + key: row.key, + className: 'bt-project-row', + 'data-task-id': row.id, + 'data-state': row.state ?? undefined, + role: open === undefined ? undefined : 'button', + tabIndex: open === undefined ? undefined : 0, + onClick: open, + onKeyDown: open === undefined ? undefined : event => { if (event.key === 'Enter' || event.key === ' ') open() }, + }, + h('span', { className: 'bt-project-title-wrap' }, + h('span', { className: 'bt-project-title' }, row.title ?? `#${row.id}`)), + note) + }))) +} + +const countText = value => (value === null ? t('unknown') : String(value)) + // The record beside the cards (#1767): decisions first, then the meetings that reached one, and // a meeting still open or blocked listed apart — never as a decision. Every row is text the host // already answered; the only thing this reads again is the hash it was handed. @@ -224,13 +300,20 @@ function ProjectBoardRecord({ record }) { meetings.pending.length === 0 ? null : pendingRows) } -export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }) +export function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, queues, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings, queues }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), + // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown. + view.counts === null ? null : h('div', { className: 'bt-project-counts', 'data-counts': 'waiting,inflight,blocked,done' }, + h('span', null, [t('Waiting'), ': ', h('b', { key: 'w' }, countText(view.counts.waiting))]), + h('span', null, [t('In flight'), ': ', h('b', { key: 'f' }, countText(view.counts.inflight))]), + h('span', null, [t('Blocked'), ': ', h('b', { key: 'b' }, countText(view.counts.blocked))]), + h('span', null, [t('Done'), ': ', h('b', { key: 'd' }, countText(view.counts.done))])), // Management affordances are drawn only for the owner; the refusal itself is the host's. view.canManage ? h('div', { className: 'bt-project-actions' }, h('button', { type: 'button' }, t('Project settings')), h('button', { type: 'button' }, t('Members'))) : null, h(ProjectBoardRows, { view, onOpen }), + h(ProjectQueueGroups, { view, onOpen }), h(ProjectBoardRecord, { record: view.record })) } @@ -273,7 +356,9 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { read('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) + // The groups are the same read as the rows, so the header cannot disagree with the cards. + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null } + if (live) setState({ project: record?.project ?? project, rows: board?.tasks ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -297,7 +382,7 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { // that hash and lands on the Bot or group the task belongs to. : h(ProjectBoardPane, { project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, - decisions: state.decisions, meetings: state.meetings, + decisions: state.decisions, meetings: state.meetings, queues: state.queues, // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and // lands on that Bot or group. Nothing here re-decides which board that is. onOpen: (board) => { diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index ee8c091d3..08f9a5565 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -7,6 +7,10 @@ // folder goes through `listProjectState`, which already hashes every file, and a decision file // is listed with a name, a date and one title line, never its body. // +// #1768 adds the queue beside those: the waiting cards of a role queue, the cards a claim holds +// (with its lease live or stalled) and the blocked cards, each with the line it already stores. +// They are derived from the same `state.tasks` pass as the rows, never a second store. +// // Read-only by construction: it reads `state.tasks`, `state.meetings` and the Project bindings, // asks each registered provider for the workflow refs of tasks that still exist, and returns the // sections. It writes nothing, starts nothing and is not called unless someone asks for the board. @@ -17,6 +21,44 @@ import { DECISIONS_DIR, listProjectState } from './project-state.js' const text = value => (typeof value === 'string' ? value.trim() : '') +// The queue, the leases and the blocked cards are host truth the board never copied (#1768): +// a member's role queue is `queueRole` on an open card with no `claimedBy` (`tasks.js`), a lease is +// the one `tasks.js` writes, and a blocked card already stores the question it waits on. This read +// derives those three groups from those fields, oldest wait first, and writes nothing. +const ONE_LINE = 200 +const oneLine = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, ONE_LINE) : '') +const stampOf = value => (Number.isFinite(value) ? value : null) + +// Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's +// own `createdAt`, so the board shows a duration a reader can check, not a guess. +export function waitingOf(task, now) { + if (task === null || typeof task !== 'object') return null + if (task.status !== 'open') return null + const role = oneLine(task.queueRole) + if (role === '' || text(task.claimedBy) !== '') return null + const since = stampOf(task.createdAt) + return { id: String(task.id), role, since, waitedMs: since === null ? null : Math.max(0, now - since) } +} + +// In flight: the card a member holds. A live lease reads running; an expired one reads stalled — +// the card record is untouched either way, recovery stays `tasks.js`'s own path (#1720). +export function inflightOf(task) { + if (task === null || typeof task !== 'object') return null + if (task.status !== 'claimed' && task.status !== 'working') return null + // The holder is the claim, not the assignee label: a card nobody claimed is waiting work, and + // only the queue's own claim (or the lease that claim wrote) says a member holds it. + const holder = oneLine(task.claimedBy) || oneLine(task.lease?.holder) + if (holder === '') return null + return { id: String(task.id), holder } +} + +// Blocked: the reason is the card's own line, never a second copy. +export function blockedOf(task) { + if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null + const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || '' + return { id: String(task.id), reason } +} + // A decision is read for its title line only: the head of the file is enough for a heading, and // the body stays where it is. The bound also keeps a megabyte file from being read to answer one // line; the hash the reader needs comes from `listProjectState`, not from this read. @@ -176,15 +218,21 @@ export function install(rt) { const id = text(projectId) const empty = { rows: [], + waiting: [], + inflight: [], + blocked: [], + counts: { waiting: 0, inflight: 0, blocked: 0, done: 0 }, decisions: { dir: null, files: [], reason: 'no Project: nothing to read' }, meetings: { decided: [], pending: [], reason: 'no Project: nothing to read' }, } if (id === '') return empty const project = rt.readProject?.(id) if (!project) return empty - const tasks = rt.state?.tasks ?? {} + // A store that cannot be read is not an empty store (#1768): `null` keeps the two apart. + const tasks = rt.state?.tasks + const readable = tasks !== null && typeof tasks === 'object' const refs = new Map() - for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks, taskId) }) ?? []) { + for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks ?? {}, taskId) }) ?? []) { refs.set(ref.taskId, ref) } // #1723: a row is painted only when the caller could open that card through `tasks`. The @@ -192,7 +240,8 @@ export function install(rt) { // nothing, because an unreadable rule is not a reason to widen a board. const visible = typeof rt.taskVisible === 'function' ? rt.taskVisible : () => false const rows = [] - for (const task of Object.values(tasks)) { + const mine = [] + for (const task of readable ? Object.values(tasks) : []) { if (task === null || typeof task !== 'object') continue if (!visible(task, actor)) continue const board = boardOf(task) @@ -209,6 +258,7 @@ export function install(rt) { ? ownProject.id === id ? board.kind : null : runProject?.id === id ? 'workflow' : null if (source === null) continue + mine.push(task) const fromRun = ref !== undefined && (runProject === null || runProject.id === id) const audit = typeof rt.taskAudit === 'function' ? rt.taskAudit(task.id) : '' rows.push({ @@ -232,8 +282,40 @@ export function install(rt) { ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } + const done = rows.filter(row => row.column === 'done').length + // An unreadable store answers unknown (#1768): `0` would claim the queue is empty, and the + // board must not turn "cannot read" into "nothing waiting". + const queues = !readable + ? { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } + : buildQueues(mine, done) const decisions = await decisionsOf(project) - return { rows, decisions, meetings: meetingsOf(project, decisions) } + return { rows, ...queues, decisions, meetings: meetingsOf(project, decisions) } + } + const buildQueues = (mine, done) => { + const now = typeof rt.now === 'function' ? rt.now() : Date.now() + const waiting = [] + const inflight = [] + const blocked = [] + for (const task of mine) { + const wait = waitingOf(task, now) + if (wait !== null) waiting.push(wait) + const flight = inflightOf(task) + if (flight !== null) { + const lease = task.lease !== null && typeof task.lease === 'object' ? task.lease : null + const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now + inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live }) + } + const block = blockedOf(task) + if (block !== null) blocked.push(block) + } + // Oldest wait first: the card that has waited longest is the one a person wants next. + waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id)) + return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } } } rt.projectBoard = projectBoard + // The three groups are their own read over the same pass, for a caller that wants only the queue. + rt.projectBoardQueues = async (projectId, actor) => { + const board = await projectBoard(projectId, actor) + return { waiting: board.waiting, inflight: board.inflight, blocked: board.blocked, counts: board.counts } + } } diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index 3ee6fd7b8..c5b38709d 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -38,7 +38,7 @@ runInNewContext(bundle.outputFiles[0].text, { throw new Error(`no platform module ${name}`) }, }) -const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView } = module.exports +const { ProjectBoardOverlay, ProjectBoardPane, projectBoardView, waitedLabel } = module.exports const render = (component, props) => { index = 0; return component(props) } const flush = async () => { const queued = effects @@ -200,3 +200,53 @@ test('#1767: an empty record names the folder it read and the missing decision f assert.equal(bare.record.decisions.count, 0) assert.match(bare.record.decisions.emptyText, /no working folder/) }) + +// #1768: the three groups beside the columns and the header counts. The host's read is passed in +// as it came — including a `null` count, which must survive as "unknown" and never become 0. +const queues = { + waiting: [ + { id: 'task-w1', role: 'Dev', since: 100, waitedMs: 3 * 3600 * 1000 }, + { id: 'task-w2', role: 'Dev', since: 200, waitedMs: 5 * 60 * 1000 }, + ], + inflight: [{ id: 'task-b1', holder: 'Dev4', lease: 'expired', stalled: true }], + blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?' }], + counts: { waiting: 2, inflight: 1, blocked: 1, done: 1 }, +} + +test('#1768: the view carries the three groups and the header counts', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, tasks: [], queues })) + assert.deepEqual(view.counts, { waiting: 2, inflight: 1, blocked: 1, done: 1 }) + assert.deepEqual(view.queues.map(entry => entry.group), ['waiting', 'inflight', 'blocked']) + assert.deepEqual(view.queues[0].items.map(item => [item.id, item.role]), [['task-w1', 'Dev'], ['task-w2', 'Dev']]) + assert.deepEqual(view.queues[1].items.map(item => [item.id, item.holder, item.state]), [['task-b1', 'Dev4', 'stalled']]) + assert.deepEqual(view.queues[2].items.map(item => [item.id, item.reason]), [['task-g1', 'Which branch should this ship on?']]) + assert.equal(waitedLabel(3 * 3600 * 1000), '3h') + assert.equal(waitedLabel(5 * 60 * 1000), '5m') +}) + +test('#1768: an unreadable count stays unknown and is never rendered as 0', () => { + const view = plain(projectBoardView({ + project: { id: 'project-1', name: 'FlyBot' }, rows: [], tasks: [], + queues: { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } }, + })) + assert.deepEqual(view.counts, { waiting: null, inflight: null, blocked: null, done: null }) + const nodes = all(ProjectBoardPane({ project: view.projectId === null ? {} : { id: 'project-1', name: 'FlyBot' }, rows: [], canManage: false, tasks: [], queues: { waiting: [], inflight: [], blocked: [], counts: { waiting: null, inflight: null, blocked: null, done: null } } })) + const text = nodes.map(textOf).join(' | ') + assert.ok(/unknown/.test(text), text) + assert.ok(!/Waiting: 0/.test(text), text) + // A board with nothing readable is its own state, not a silent zero. + assert.equal(view.empty, true) +}) + +test('#1768: the pane draws each group and a blocked card shows its reason', () => { + const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues })) + const sections = nodes.filter(node => node && node.props && node.props['data-queue']) + assert.deepEqual(sections.map(node => node.props['data-queue']), ['waiting', 'inflight', 'blocked']) + const text = nodes.map(textOf).join(' | ') + assert.ok(text.includes('Dev4 · stalled'), text) + assert.ok(text.includes('Which branch should this ship on?'), text) + assert.ok(text.includes('3h'), text) + // The rows keep their task id, so the click-through to `#task=` is unchanged. + const ids = nodes.filter(node => node && node.props && node.props['data-task-id']).map(node => node.props['data-task-id']) + assert.ok(ids.includes('task-w1') && ids.includes('task-b1') && ids.includes('task-g1'), JSON.stringify(ids)) +}) \ No newline at end of file diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index b53d4356b..524cdc07e 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -361,3 +361,67 @@ test('#1767: a Project without a decisions folder answers an empty list with the rmSync(dir, { recursive: true, force: true }) } }) + +// #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of +// each shape on Project 1 and one on Project 2, so every group can be asked what it must not show. +const queueSeed = () => { + const team = seed() + team.rooms.r2 = { id: 'r2', name: 'Garden', members: ['a'], mode: 'admin', admin: 'a', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-p2' } + team.projects['project-p2'] = { id: 'project-p2', name: 'P2', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main', ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active' } + team.tasks['task-w1'] = { id: 'task-w1', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'First queued', createdAt: 10, updatedAt: 10 } + team.tasks['task-w2'] = { id: 'task-w2', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r1' }, title: 'Second queued', createdAt: 20, updatedAt: 20 } + team.tasks['task-hold'] = { id: 'task-hold', kind: 'group', status: 'claimed', queueRole: 'Dev', claimedBy: 'Dev1', origin: { roomId: 'r1' }, title: 'Claimed', lease: { holder: 'Dev1', takenAt: Date.now() - 1000, expiresAt: Date.now() + 600000 }, createdAt: 30, updatedAt: 30 } + team.tasks['task-blocked'] = { id: 'task-blocked', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Blocked card', inputRequest: { id: 'q1', question: 'Which branch should this ship on?' }, createdAt: 40, updatedAt: 40 } + team.tasks['task-far'] = { id: 'task-far', kind: 'group', status: 'open', queueRole: 'Dev', queueBy: 'a', origin: { roomId: 'r2' }, title: 'Other Project', createdAt: 50, updatedAt: 50 } + return team +} + +test('#1768: two queued cards and one claimed read as 2 waiting / 1 in flight, holder named', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const queues = await rt.projectBoardQueues('project-p1') + assert.equal(queues.counts.waiting, 2) + assert.equal(queues.counts.inflight, 1) + // Oldest wait first, with the role it is queued for. + assert.deepEqual(queues.waiting.map(item => item.id), ['task-w1', 'task-w2']) + assert.deepEqual(queues.waiting.map(item => item.role), ['Dev', 'Dev']) + assert.ok(Number.isFinite(queues.waiting[0].waitedMs) && queues.waiting[0].waitedMs >= queues.waiting[1].waitedMs) + // The holder is the member that claimed it, and a live lease reads running. + assert.deepEqual(queues.inflight.map(item => [item.id, item.holder, item.lease, item.stalled]), [['task-hold', 'Dev1', 'live', false]]) + } finally { await stop() } +}) + +test('#1768: an expired lease reads stalled and the card record does not move', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const before = JSON.stringify(rt.state.tasks['task-hold']) + rt.state.tasks['task-hold'].lease.expiresAt = Date.now() - 1000 + const moved = JSON.stringify(rt.state.tasks['task-hold']) + assert.notEqual(moved, before, 'the test moved the lease itself') + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.inflight.map(item => [item.id, item.stalled, item.lease]), [['task-hold', true, 'expired']]) + assert.equal(JSON.stringify(rt.state.tasks['task-hold']), moved, 'reading the board wrote nothing') + } finally { await stop() } +}) + +test('#1768: a blocked card carries its own reason and another Project is in none of the groups', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.blocked.map(item => [item.id, item.reason]), [['task-blocked', 'Which branch should this ship on?']]) + // task-far is queued for the same role but lives on Project 2, and the counts are this Project's. + const ids = [...queues.waiting, ...queues.inflight, ...queues.blocked].map(item => item.id) + assert.ok(!ids.includes('task-far'), JSON.stringify(ids)) + assert.deepEqual([queues.counts.waiting, queues.counts.inflight, queues.counts.blocked], [2, 1, 1]) + } finally { await stop() } +}) + +test('#1768: an unreadable queue answers unknown, never zero', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + rt.state.tasks = null + const queues = await rt.projectBoardQueues('project-p1') + assert.deepEqual(queues.counts, { waiting: null, inflight: null, blocked: null, done: null }) + assert.deepEqual([queues.waiting, queues.inflight, queues.blocked], [[], [], []]) + } finally { await stop() } +})