From 5ea9acbb023a3e119c353437cfb87d021cf5765e Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 13:14:23 +0800 Subject: [PATCH] Add the decisions and meetings record to the Project board Replay onto main 92a4cf262cb8f2469f800f12e5cfe006abb4d538: update-branch answered 422 (a real conflict in core/src/host/store.js), so the change is re-applied on main's tree. main's retiredBot predicate and this change's board sections both stand there, and core/client.js and patches/ds-bot/ were regenerated from the merged core/. Fixes #1767 Written-by: Replay Docs-not-needed: the subsystem pages describe the board as a read model of the cards; this adds no new path and project-board.js keeps its one read. --- .../1767-project-board-record.added.md | 1 + core/client.js | 122 +- core/src/client/i18n.js | 5 + core/src/client/project-board.js | 118 +- core/src/host/project-assistant.js | 5 +- core/src/host/project-board.js | 175 +- core/src/host/store.js | 17 +- core/test/project-assistant.test.js | 3 +- core/test/project-board-view.test.js | 61 + core/test/project-board.test.js | 150 +- core/test/task-contract.test.js | 4 +- patches/ds-bot/0001-core-fixes.patch | 83 +- patches/ds-bot/0002-flybot-api-host.patch | 57 +- patches/ds-bot/0008-client-bundle.patch | 6698 ++++++----------- .../ds-bot/files/src/client/project-board.js | 118 +- .../files/src/host/project-assistant.js | 5 +- .../ds-bot/files/src/host/project-board.js | 175 +- .../files/test/project-assistant.test.js | 3 +- .../files/test/project-board-view.test.js | 61 + .../ds-bot/files/test/project-board.test.js | 150 +- .../ds-bot/files/test/task-contract.test.js | 4 +- 21 files changed, 3581 insertions(+), 4434 deletions(-) create mode 100644 changelog.d/1767-project-board-record.added.md diff --git a/changelog.d/1767-project-board-record.added.md b/changelog.d/1767-project-board-record.added.md new file mode 100644 index 000000000..b780c81a1 --- /dev/null +++ b/changelog.d/1767-project-board-record.added.md @@ -0,0 +1 @@ +The Project board now lists the Project's decisions beside its cards: every `decisions/*.md` file in the Project's working folder with its date, title line and sha256, and the meetings that reached `decided` — a meeting still deliberating or blocked is listed apart and never as a decision. A folder with no decision file says which folder was read instead of drawing an empty box, and a decided meeting whose file was deleted stays listed with no path. diff --git a/core/client.js b/core/client.js index fd183b411..2f0a32dd9 100644 --- a/core/client.js +++ b/core/client.js @@ -1357,6 +1357,11 @@ var ZH = { "Project settings": "项目设置", "Project board": "项目看板", "No Project here.": "这里没有项目。", + // The record beside the cards (#1767): the folder was read, and what it holds. + Decisions: "决策", + Meetings: "会议", + "Not decided": "未决定", + "decision file gone": "决策文件已不在", // The Project board names its columns with the same keys the task record uses. "not-started": "未开始", "in-progress": "进行中", @@ -19681,6 +19686,18 @@ var PROJECT_BOARD_CSS = ` .bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} +/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new + component language: a heading, a list, and a monospace hash the reader can compare. */ +.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} +.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} +.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} +.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} +.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} +.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} `; var PROJECT_SOURCES = ["bot", "group", "workflow"]; @@ -19698,7 +19715,59 @@ var boardOfRow = (row, task) => { } return null; }; -function projectBoardView({ project, rows, canManage, tasks } = {}) { +var decisionOf = (file) => { + if (file === null || typeof file !== "object") return null; + const path = text(file.path); + if (path === "") return null; + return { + key: `decision:${path}`, + path, + name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), + date: text(file.date), + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12) + }; +}; +var meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== "object") return null; + const id = text(meeting.id); + if (id === "") return null; + return { + key: `meeting:${id}`, + id, + date: text(meeting.date), + question: text(meeting.question) || text(meeting.title), + owner: text(meeting.owner) || null, + status, + path: text(meeting.path), + blockingQuestion: text(meeting.blockingQuestion) || null + }; +}; +var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; +function projectBoardRecord({ decisions, meetings } = {}) { + const dir = text(decisions?.dir) || null; + const files = listOf(decisions?.files); + const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; + const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; + return { + decisions: { + dir, + files, + count: files.length, + empty: files.length === 0, + emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) + }, + meetings: { + decided, + pending, + count: decided.length + pending.length, + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." + } + }; +} +function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { 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) => ({ @@ -19727,7 +19796,8 @@ function projectBoardView({ project, rows, canManage, tasks } = {}) { groups, count: list.length, empty: list.length === 0, - emptyText: "Nothing under this Project yet." + emptyText: "Nothing under this Project yet.", + record: projectBoardRecord({ decisions, meetings }) }; } function ProjectBoardRows({ view, onOpen }) { @@ -19768,15 +19838,53 @@ function ProjectBoardRows({ view, onOpen }) { }) )); } -function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks }); +function ProjectBoardRecord({ record }) { + const { decisions, meetings } = record; + const decisionRows = decisions.files.map((file) => (0, import_react46.createElement)( + "div", + { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 }, + (0, import_react46.createElement)("span", { className: "bt-project-date" }, file.date), + (0, import_react46.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), + (0, import_react46.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) + )); + const decidedRows = meetings.decided.map((meeting) => (0, import_react46.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, + (0, import_react46.createElement)("span", { className: "bt-project-date" }, meeting.date), + (0, import_react46.createElement)("span", { className: "bt-project-title" }, meeting.question), + meeting.owner === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), + // A decided meeting whose file is gone still reads as decided; the missing path says so + // instead of the row disappearing with the file. + meeting.path === "" ? (0, import_react46.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react46.createElement)("span", { className: "bt-project-path" }, meeting.path) + )); + const pendingRows = meetings.pending.map((meeting) => (0, import_react46.createElement)( + "div", + { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status }, + (0, import_react46.createElement)("span", { className: "bt-project-date" }, meeting.date), + (0, import_react46.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), + (0, import_react46.createElement)("span", { className: "bt-project-status" }, meeting.status) + )); + return (0, import_react46.createElement)( + "section", + { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) }, + (0, import_react46.createElement)("h4", null, t("Decisions")), + decisions.empty ? (0, import_react46.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, + (0, import_react46.createElement)("h4", null, t("Meetings")), + meetings.empty ? (0, import_react46.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, + meetings.pending.length === 0 ? null : (0, import_react46.createElement)("h5", null, t("Not decided")), + meetings.pending.length === 0 ? null : pendingRows + ); +} +function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { + const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }); 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), // 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)(ProjectBoardRows, { view, onOpen }), + (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) ); } function ProjectBoardOverlay({ actions } = {}) { @@ -19816,7 +19924,7 @@ function ProjectBoardOverlay({ actions } = {}) { call("tasks", {}) ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }); + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); } @@ -19848,6 +19956,8 @@ function ProjectBoardOverlay({ actions } = {}) { rows: state.rows, canManage: state.canManage, tasks: state.tasks, + decisions: state.decisions, + meetings: state.meetings, // 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 79f89ab76..fdb570376 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1310,6 +1310,11 @@ const ZH = { 'Project settings': '项目设置', 'Project board': '项目看板', 'No Project here.': '这里没有项目。', + // The record beside the cards (#1767): the folder was read, and what it holds. + Decisions: '决策', + Meetings: '会议', + 'Not decided': '未决定', + 'decision file gone': '决策文件已不在', // The Project board names its columns with the same keys the task record uses. 'not-started': '未开始', 'in-progress': '进行中', diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 3304c5702..5b33db460 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -21,6 +21,18 @@ export const PROJECT_BOARD_CSS = ` .bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} +/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new + component language: a heading, a list, and a monospace hash the reader can compare. */ +.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} +.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} +.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} +.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} +.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} +.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} ` @@ -47,10 +59,72 @@ const boardOfRow = (row, task) => { return null } +// One decision file as the board lists it: the name, the day in the name, the title line and the +// hash the host computed. Nothing here reads a body — the view receives what the read answered. +const decisionOf = (file) => { + if (file === null || typeof file !== 'object') return null + const path = text(file.path) + if (path === '') return null + return { + key: `decision:${path}`, + path, + name: text(file.name) || path.slice(path.lastIndexOf('/') + 1), + date: text(file.date), + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12), + } +} + +const meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== 'object') return null + const id = text(meeting.id) + if (id === '') return null + return { + key: `meeting:${id}`, + id, + date: text(meeting.date), + question: text(meeting.question) || text(meeting.title), + owner: text(meeting.owner) || null, + status, + path: text(meeting.path), + blockingQuestion: text(meeting.blockingQuestion) || null, + } +} + +const listOf = (value) => (Array.isArray(value) ? value.map(item => decisionOf(item)).filter(item => item !== null) : []) + +// The record beside the cards (#1767). A section with nothing in it still says which folder was +// read and why it holds no file, because an empty box reads as "there is nothing to decide", and +// a Project naming no folder is a different answer from a folder with no decision in it. +export function projectBoardRecord({ decisions, meetings } = {}) { + const dir = text(decisions?.dir) || null + const files = listOf(decisions?.files) + const decided = Array.isArray(meetings?.decided) ? meetings.decided.map(item => meetingOf(item, 'decided')).filter(item => item !== null) : [] + const pending = Array.isArray(meetings?.pending) ? meetings.pending.map(item => meetingOf(item, item?.status ?? 'deliberating')).filter(item => item !== null) : [] + return { + decisions: { + dir, + files, + count: files.length, + empty: files.length === 0, + emptyText: text(decisions?.reason) || (dir === null ? 'This Project names no working folder.' : `No decision file in ${dir}/decisions.`), + }, + meetings: { + decided, + pending, + count: decided.length + pending.length, + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || 'No meeting of this Project is recorded.', + }, + } +} + // Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from // `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. -export function projectBoardView({ project, rows, canManage, tasks } = {}) { +// `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 } = {}) { 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) !== '') @@ -86,6 +160,7 @@ export function projectBoardView({ project, rows, canManage, tasks } = {}) { count: list.length, empty: list.length === 0, emptyText: 'Nothing under this Project yet.', + record: projectBoardRecord({ decisions, meetings }), } } @@ -118,13 +193,45 @@ function ProjectBoardRows({ view, onOpen }) { }))) } -export function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks }) +// 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. +function ProjectBoardRecord({ record }) { + const { decisions, meetings } = record + const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision', 'data-path': file.path, 'data-sha256': file.sha256 }, + h('span', { className: 'bt-project-date' }, file.date), + h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), + h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + const decidedRows = meetings.decided.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': 'decided' }, + h('span', { className: 'bt-project-date' }, meeting.date), + h('span', { className: 'bt-project-title' }, meeting.question), + meeting.owner === null ? null : h('span', { className: 'bt-project-owner' }, `@${meeting.owner}`), + // A decided meeting whose file is gone still reads as decided; the missing path says so + // instead of the row disappearing with the file. + meeting.path === '' + ? h('span', { className: 'bt-project-missing' }, 'decision file gone') + : h('span', { className: 'bt-project-path' }, meeting.path))) + const pendingRows = meetings.pending.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': meeting.status }, + h('span', { className: 'bt-project-date' }, meeting.date), + h('span', { className: 'bt-project-title' }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), + h('span', { className: 'bt-project-status' }, meeting.status))) + return h('section', { className: 'bt-project-record', 'data-decisions': String(decisions.count), 'data-meetings': String(meetings.count) }, + h('h4', null, t('Decisions')), + decisions.empty ? h('div', { className: 'bt-project-empty bt-project-record-empty' }, t(decisions.emptyText)) : decisionRows, + h('h4', null, t('Meetings')), + meetings.empty ? h('div', { className: 'bt-project-empty bt-project-record-empty' }, t(meetings.emptyText)) : decidedRows, + meetings.pending.length === 0 ? null : h('h5', null, t('Not decided')), + 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 }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), // 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(ProjectBoardRows, { view, onOpen }), + h(ProjectBoardRecord, { record: view.record })) } // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. @@ -164,7 +271,7 @@ export function ProjectBoardOverlay({ actions } = {}) { call('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -186,6 +293,7 @@ export function ProjectBoardOverlay({ actions } = {}) { // 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, // 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-assistant.js b/core/src/host/project-assistant.js index a532b0326..bffb6e0d4 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -438,7 +438,10 @@ export function install(rt) { const listed = text(ownerId) === '' ? ownersOf(project) : [text(ownerId)] const owners = listed.length === 0 ? [''] : listed const who = identity(project.id) - const rows = rt.projectBoard?.(project.id) ?? [] + // The board read is the same one the page uses (#1767): the digest wants its cards, not the + // decisions and meetings that answer beside them. + const board = (await rt.projectBoard?.(project.id)) ?? [] + const rows = Array.isArray(board) ? board : (board.rows ?? []) const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) const files = state?.ok === true ? state.files : [] const results = [] diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index 808b687da..ee8c091d3 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -2,13 +2,59 @@ // run names the same id through `run.task.id` and never mints a second task, so a card a run // consumed is one row that carries the run, not two views of the same work. // -// Read-only by construction: it reads `state.tasks` and the Project bindings, asks each -// registered provider for the workflow refs of tasks that still exist, and returns rows. It -// writes nothing, starts nothing and is not called unless someone asks for the board. +// #1767 adds the other half of the record beside the cards: the `decisions/*.md` files of the +// Project's working folder and the meetings that reached `decided`. It is the same read — the +// 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. +// +// 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. +import { open } from 'node:fs/promises' +import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' +import { DECISIONS_DIR, listProjectState } from './project-state.js' const text = value => (typeof value === 'string' ? value.trim() : '') +// 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. +const TITLE_BYTES = 4096 +const TITLE_MAX = 200 +const DECISIONS_MAX = 200 +const MEETINGS_MAX = 100 + +const fileName = path => path.slice(path.lastIndexOf('/') + 1) +// The day a decision file records is the one in its name (`decisions/2026-10-30-x.md`), so two +// files of one day read as one day even when the folder was copied and their mtimes differ. +const dateOfName = (name) => { + const match = /^(\d{4}-\d{2}-\d{2})/.exec(name) + return match === null ? '' : match[1] +} + +async function titleOf(dir, path) { + let handle + try { + handle = await open(join(dir, path), 'r') + const buffer = Buffer.alloc(TITLE_BYTES) + const { bytesRead } = await handle.read(buffer, 0, TITLE_BYTES, 0) + for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) { + const line = raw.trim() + if (line === '') continue + return line.replace(/^#+\s*/, '').slice(0, TITLE_MAX) + } + return '' + } catch { + // A file that vanished between the listing and this read answers no title rather than a + // failed board: the row itself is the other read's. + return '' + } finally { + // Whichever way the read ended, the handle is closed and nothing is left to close. + if (handle !== undefined) await handle.close().catch(() => { /* nothing left to close */ }) + } +} + // The board a card belongs to. A group card names its room; a Bot card names its owner. A card // with neither (a run-only card) has no board and resolves through the run's own binding. const boardOf = (task) => { @@ -20,12 +66,122 @@ const boardOf = (task) => { } export function install(rt) { - // Rows for one Project, deduped by task id. An unknown Project and a Project with nothing - // under it both answer `[]`: reading a board is not a reason to throw. - const projectBoard = (projectId, actor) => { + // The folder a Project's decisions live in. A Project names one itself, and when it names none + // the folder is the one its members work in — the same folder a meeting resolves through + // `workDirFor` before it archives a decision, so the reader and the writer look in one place. + const folderOf = (project) => { + const own = text(project?.workDir) + if (own !== '') return own + for (const room of Object.values(rt.state?.rooms ?? {})) { + if (room === null || typeof room !== 'object') continue + const dir = text(room.workDir) + if (dir !== '' && rt.projectOfMember?.('group', room.id)?.id === project?.id) return dir + } + for (const bot of Object.values(rt.state?.bots ?? {})) { + if (bot === null || typeof bot !== 'object') continue + const dir = text(bot.workDir) + if (dir !== '' && rt.projectOfMember?.('bot', bot.id)?.id === project?.id) return dir + } + return '' + } + + const decisionsOf = async (project) => { + const dir = folderOf(project) + if (dir === '') return { dir: null, files: [], reason: 'no working folder: this Project names none' } + let state + try { + state = await listProjectState(dir) + } catch (error) { + return { dir, files: [], reason: `unreadable: ${error?.message ?? String(error)}` } + } + if (state.ok !== true) return { dir, files: [], reason: state.reason } + const found = state.files + .filter(file => file.path.startsWith(`${DECISIONS_DIR}/`) && file.path.endsWith('.md')) + .slice(0, DECISIONS_MAX) + const files = [] + for (const file of found) { + const name = fileName(file.path) + files.push({ + path: file.path, + name, + date: dateOfName(name), + title: await titleOf(dir, file.path), + bytes: file.bytes, + sha256: file.sha256, + mtime: file.mtime, + }) + } + return { dir, files, reason: files.length === 0 ? `no decision file in ${join(dir, DECISIONS_DIR)}` : '' } + } + + // A meeting names the Project it belongs to by id or by name (the tool's `project` argument is + // free text), so both forms resolve here rather than one being silently dropped. + const inProject = (meeting, project) => { + const named = text(meeting?.project).toLowerCase() + if (named === '') return false + return [text(project?.id), text(project?.name)] + .filter(value => value !== '') + .some(value => value.toLowerCase() === named) + } + + const meetingsOf = (project, decisions) => { + const known = new Map(decisions.files.map(file => [file.path, file])) + const mine = Object.values(rt.state?.meetings ?? {}) + .filter(meeting => meeting !== null && typeof meeting === 'object' && inProject(meeting, project)) + .sort((a, b) => (b.decidedAt ?? b.updatedAt ?? 0) - (a.decidedAt ?? a.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + const decided = [] + const pending = [] + for (const meeting of mine) { + const id = text(meeting.id) + const question = text(meeting.question ?? meeting.title) + if (meeting.status === 'decided') { + // A decided meeting is the decision's record, not the file's: the file may have been + // deleted or copied away, and the meeting still says what was decided. `path` is empty + // exactly then, and the reader can see the file the record names is gone. + const named = text(meeting.decisionFile) + const file = known.get(named) + decided.push({ + id, + date: text(meeting.decidedAt) !== '' ? new Date(meeting.decidedAt).toISOString().slice(0, 10) : '', + question, + owner: text(meeting.owner) || null, + path: file === undefined ? '' : file.path, + ...(file === undefined ? {} : { sha256: file.sha256 }), + }) + continue + } + // Only a decided meeting is a decision. A meeting still open, deliberating or blocked is + // listed beside those, with the state it is in, so nobody reads a question as an outcome. + if (meeting.status === 'open' || meeting.status === 'deliberating' || meeting.status === 'blocked') { + pending.push({ + id, + date: text(meeting.createdAt) !== '' ? new Date(meeting.createdAt).toISOString().slice(0, 10) : '', + question, + status: meeting.status, + ...(meeting.status === 'blocked' && text(meeting.blockingQuestion) !== '' ? { blockingQuestion: text(meeting.blockingQuestion) } : {}), + }) + } + } + return { + decided: decided.slice(0, MEETINGS_MAX), + pending: pending.slice(0, MEETINGS_MAX), + reason: mine.length === 0 ? 'no meeting of this Project is recorded' : '', + } + } + + // One board: the task rows, then the decisions and meetings that read beside them. An unknown + // Project and a Project with nothing under it both answer empty sections: reading a board is + // not a reason to throw. + const projectBoard = async (projectId, actor) => { const id = text(projectId) - if (id === '') return [] - if (!rt.readProject?.(id)) return [] + const empty = { + rows: [], + 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 ?? {} const refs = new Map() for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks, taskId) }) ?? []) { @@ -76,7 +232,8 @@ export function install(rt) { ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } - return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + const decisions = await decisionsOf(project) + return { rows, decisions, meetings: meetingsOf(project, decisions) } } rt.projectBoard = projectBoard } diff --git a/core/src/host/store.js b/core/src/host/store.js index 577bbf494..9b1eacd8f 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -638,10 +638,19 @@ 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): 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': return { tasks: rt.projectBoard?.(projectText(body.id, 200), actor) ?? [] } + // 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. + case 'project-board': { + const board = (await rt.projectBoard?.(projectText(body.id, 200), actor)) ?? [] + const rows = Array.isArray(board) ? board : (board.rows ?? []) + const sections = Array.isArray(board) ? {} : board + return { + 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' }, + } + } default: throw projectRefusal(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js index 5ba8cca6c..4f95aac49 100644 --- a/core/test/project-assistant.test.js +++ b/core/test/project-assistant.test.js @@ -522,7 +522,8 @@ test('a card lists for the owner of the Bot that owns it, through the real task } await rt.taskEndpoint('task-accept', { id: moving }, null) await rt.taskEndpoint('task-start', { id: moving }, null) - const rows = rt.projectBoard(project.id) + // #1767: the same call now answers the record beside the rows, so the rows are read off it. + const rows = (await rt.projectBoard(project.id)).rows assert.equal(rows.length, 3) assert.ok(rows.every(row => row.assignee === null), 'the board carries a Bot name or null, never a member id') assert.equal(actionableFor(rows, 'owner-1').length, 0, 'the raw helper sees no owner without a resolver') diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index f42af09b2..c05847f36 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -105,3 +105,64 @@ test('an empty Project renders the empty text and no row', () => { assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) }) + +// #1767: the record beside the cards. The view passes through what the host read — a decision +// with its hash, the meetings that reached one, and the ones still open listed apart — and an +// empty section says which folder was read instead of drawing an empty box. +const decisions = { + dir: '/team/flybot', + files: [ + { path: 'decisions/2026-10-30-adopt.md', name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }, + { path: 'decisions/2026-10-31-second.md', name: '2026-10-31-second.md', date: '2026-10-31', title: 'Keep decisions small', sha256: 'b'.repeat(64), mtime: '2026-10-31T01:00:00.000Z' }, + ], + reason: '', +} +const meetings = { + decided: [{ id: 'meeting-d', date: '2026-10-30', question: 'Which board carries the record?', owner: 'Chief', path: 'decisions/2026-10-30-adopt.md' }], + pending: [{ id: 'meeting-o', date: '2026-10-30', question: 'Should the board page?', status: 'deliberating' }], + reason: '', +} + +test('#1767: the view carries the decisions with their hash and the meetings apart', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, decisions, meetings, canManage: false })) + assert.equal(view.record.decisions.count, 2) + assert.equal(view.record.decisions.empty, false) + assert.equal(view.record.decisions.files[0].shortHash, 'a'.repeat(12)) + assert.equal(view.record.decisions.files[0].title, 'Adopt the board record') + assert.equal(view.record.meetings.decided.length, 1) + assert.equal(view.record.meetings.decided[0].path, 'decisions/2026-10-30-adopt.md') + assert.equal(view.record.meetings.pending.length, 1) + assert.equal(view.record.meetings.pending[0].status, 'deliberating') +}) + +test('#1767: the pane draws the record under the cards, and a deliberating meeting is never a decision', () => { + const tree = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, decisions, meetings, canManage: false })) + const text = tree.map(textOf).join(' | ') + assert.match(text, /Adopt the board record/) + assert.match(text, /aaaaaaaaaaaa/, 'the hash the reader can compare is on screen') + assert.match(text, /Which board carries the record\?/) + assert.match(text, /Should the board page\?/) + const sections = tree.filter(node => node.props?.className === 'bt-project-record') + assert.equal(sections.length, 1) + assert.equal(sections[0].props['data-decisions'], '2') + assert.equal(sections[0].props['data-meetings'], '2') + const statuses = tree.filter(node => node.props?.['data-status'] !== undefined).map(node => node.props['data-status']) + assert.deepEqual(statuses.sort(), ['decided', 'deliberating']) +}) + +test('#1767: an empty record names the folder it read and the missing decision file', () => { + const view = plain(projectBoardView({ + project: { id: 'project-1', name: 'FlyBot' }, rows: [], canManage: false, + decisions: { dir: '/team/flybot', files: [], reason: 'no decision file in /team/flybot/decisions' }, + meetings: { decided: [], pending: [], reason: 'no meeting of this Project is recorded' }, + })) + assert.equal(view.record.decisions.empty, true) + assert.equal(view.record.decisions.emptyText, 'no decision file in /team/flybot/decisions') + assert.equal(view.record.meetings.empty, true) + assert.equal(view.record.meetings.emptyText, 'no meeting of this Project is recorded') + // The view before this slice still answers: a caller passing no sections reads an empty record, + // not a thrown one. + const bare = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [] })) + assert.equal(bare.record.decisions.count, 0) + assert.match(bare.record.decisions.emptyText, /no working folder/) +}) diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index b9f7c3e4e..b53d4356b 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -2,7 +2,7 @@ // that same id through `run.task.id`. The aggregate reads both views as one row, never twice, // and writes nothing back. import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -95,7 +95,7 @@ test('a Project board reads a group card and a run-created card without repeatin { taskId: 'task-missing', runId: 'run-3' }, ].filter(row => hasTask(row.taskId))) try { - const rows = rt.projectBoard('project-p1') + const rows = (await rt.projectBoard('project-p1')).rows assert.equal(rows.length, 2) const group = rows.find(row => row.id === 'task-group') assert.deepEqual(group, { id: 'task-group', source: 'group', column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) @@ -114,10 +114,10 @@ test('an empty Project answers [] and a missing one is not an error', async () = try { // Without a provider the group board still reads on its own; the run-created card has no // board of its own and no run binding to give, so it is not a row. - assert.deepEqual(rt.projectBoard('project-p1').map(row => [row.id, row.source]), [['task-group', 'group']]) - assert.deepEqual(rt.projectBoard('project-empty'), []) - assert.deepEqual(rt.projectBoard('project-nothing'), []) - assert.deepEqual(rt.projectBoard(''), []) + assert.deepEqual((await rt.projectBoard('project-p1')).rows.map(row => [row.id, row.source]), [['task-group', 'group']]) + assert.deepEqual((await rt.projectBoard('project-empty')).rows, []) + assert.deepEqual((await rt.projectBoard('project-nothing')).rows, []) + assert.deepEqual((await rt.projectBoard('')).rows, []) } finally { await stop() } }) @@ -128,7 +128,7 @@ test('reading the board leaves state.tasks and the run refs byte-identical', asy try { const before = JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }) const refsBefore = JSON.stringify(rt.projectRunRefs()) - rt.projectBoard('project-p1') + await rt.projectBoard('project-p1') assert.equal(JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }), before) assert.equal(JSON.stringify(rt.projectRunRefs()), refsBefore) } finally { @@ -179,13 +179,13 @@ test('#1713: a run binding never carries another Project’s card onto a board', const { rt, stop } = await start(undefined, splitSeed()) const off = rt.onProjectProvider(() => [{ taskId: 'task-group', runId: 'run-2' }, { taskId: 'task-run', runId: 'run-1' }]) try { - const p1 = rt.projectBoard('project-p1') + const p1 = (await rt.projectBoard('project-p1')).rows // Rows come newest first, so the identity of a row is its id and source, not its position. assert.deepEqual(p1.map(row => `${row.id}:${row.source}`).sort(), ['task-group:group', 'task-run:workflow']) const group = p1.find(row => row.id === 'task-group') assert.equal(group.runId, undefined, 'a row of this Project never carries another Project’s run id') // Project 2 does not inherit a card whose own board already names Project 1. - assert.deepEqual(rt.projectBoard('project-p2'), [], 'the far Project lists no card of this one') + assert.deepEqual((await rt.projectBoard('project-p2')).rows, [], 'the far Project lists no card of this one') } finally { off() await stop() @@ -229,3 +229,135 @@ test('#1723: a Project board paints only the cards `tasks` would let the member assert.deepEqual(empty.value.tasks, []) } finally { await stop() } }) + +// #1767: the record beside the cards. The Project's working folder holds two decision files and +// one meeting reached `decided`; the board lists exactly those, with the hash it computed, and a +// meeting still deliberating is never listed as a decision. +const recordSeed = () => { + const team = seed() + team.projects['project-p1'].workDir = '__WORKDIR__' + team.meetings = { + 'meeting-decided': { + id: 'meeting-decided', status: 'decided', question: 'Which board carries the record?', + owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-10-30-adopt.md', + createdAt: 1, updatedAt: 5, decidedAt: 6, + }, + 'meeting-open': { + id: 'meeting-open', status: 'deliberating', question: 'Should the board page?', + project: 'P1', createdAt: 2, updatedAt: 3, + }, + 'meeting-blocked': { + id: 'meeting-blocked', status: 'blocked', question: 'Who owns the archive?', + project: 'project-p1', blockingQuestion: 'The maintainer has not named an owner.', + createdAt: 2, updatedAt: 4, + }, + 'meeting-other': { + id: 'meeting-other', status: 'decided', question: 'Another Project’s question', + owner: 'Chief', project: 'project-p2', decisionFile: 'decisions/2026-10-30-elsewhere.md', + createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + return team +} + +// The State reader answers for a repository, so the fixture is one; the decisions folder only +// exists once this test makes it, which is the read the board performs. +const folderWithDecisions = () => { + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-')) + mkdirSync(join(dir, '.git')) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') + writeFileSync(join(dir, 'decisions', '2026-10-30-second.md'), '# Keep decisions small\n') + return dir +} + +async function startWithRecord() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = folderWithDecisions() + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const started = await start(home, team) + return { ...started, home, dir } +} + +test('#1767: the board lists two decisions with their hash and one decided meeting', async () => { + const { rt, call, home, dir, stop } = await startWithRecord() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.dir, dir) + assert.deepEqual(board.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md', 'decisions/2026-10-30-second.md']) + assert.deepEqual(board.decisions.files.map(file => file.date), ['2026-10-30', '2026-10-30']) + assert.deepEqual(board.decisions.files.map(file => file.title), ['Adopt the board record', 'Keep decisions small']) + for (const file of board.decisions.files) { + assert.match(file.sha256, /^[0-9a-f]{64}$/, 'every decision carries the hash the reader computed') + assert.equal(file.bytes, statSync(join(dir, file.path)).size) + assert.equal(file.mtime, new Date(statSync(join(dir, file.path)).mtimeMs).toISOString()) + } + // One decided meeting, linked to its file; the deliberating and blocked ones are apart, and a + // meeting of another Project is not here at all. + assert.deepEqual(board.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) + assert.equal(board.meetings.decided[0].path, 'decisions/2026-10-30-adopt.md') + assert.equal(board.meetings.decided[0].owner, 'Chief') + assert.equal(board.meetings.decided[0].question, 'Which board carries the record?') + assert.deepEqual(board.meetings.pending.map(meeting => meeting.status).sort(), ['blocked', 'deliberating']) + assert.equal(board.meetings.pending.some(meeting => meeting.id === 'meeting-decided'), false, 'a decided meeting is never pending') + assert.equal(board.meetings.decided.some(meeting => meeting.id === 'meeting-open'), false, 'a deliberating meeting is never a decision') + // The same read through the endpoint the page uses: `tasks` unchanged, two new keys. + const served = value(await call('project-board', { id: 'project-p1' })) + assert.deepEqual(served.tasks.map(row => row.id), ['task-group']) + assert.equal(served.decisions.files.length, 2) + assert.deepEqual(served.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) + // Nothing wrote: the team file is the bytes it was when the read began. + const before = readFileSync(join(home, 'state.json')) + const reread = value(await call('project-board', { id: 'project-p1' })) + assert.equal(reread.decisions.files.length, 2) + assert.deepEqual(readFileSync(join(home, 'state.json')), before, 'reading the board writes nothing') + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('#1767: a deleted decision file leaves the next read, and a decided meeting still reads decided', async () => { + const { rt, dir, stop } = await startWithRecord() + try { + rmSync(join(dir, 'decisions', '2026-10-30-second.md')) + const afterDelete = await rt.projectBoard('project-p1') + assert.deepEqual(afterDelete.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md']) + // The archived file the decided meeting names is removed too: the meeting is the record and + // stays listed, with no path — the file is gone, not the decision. + rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) + const gone = await rt.projectBoard('project-p1') + assert.equal(gone.decisions.files.length, 0) + assert.match(gone.decisions.reason, /no decision file in .*decisions/) + assert.deepEqual(gone.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('#1767: a Project without a decisions folder answers an empty list with the reason, never a throw', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-nodecisions-')) + mkdirSync(join(dir, '.git')) + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const { rt, call, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + assert.deepEqual(board.decisions.files, []) + assert.match(board.decisions.reason, /no decision file in .*decisions/, 'the empty state names the folder it read') + assert.deepEqual(board.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) + // A Project naming no folder is an answer too. + const noFolder = await rt.projectBoard('project-empty') + assert.deepEqual(noFolder.decisions.files, []) + assert.match(noFolder.decisions.reason, /no working folder/) + const served = value(await call('project-board', { id: 'project-p1' })) + assert.deepEqual(served.decisions.files, []) + assert.match(served.decisions.reason, /no decision file/) + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/core/test/task-contract.test.js b/core/test/task-contract.test.js index 88c20d878..d0545e488 100644 --- a/core/test/task-contract.test.js +++ b/core/test/task-contract.test.js @@ -225,7 +225,7 @@ test('a pre-slice record reads back and renders without the new fields (#1720)', assert.match(formatRoomBoard(app.rt.state.tasks, { roomId: 'war', now: 10, nameOf: () => 'Dev1' }), /An old card/) }) -test('the Project board reads the typed fields from the same row (#1720)', () => { +test('the Project board reads the typed fields from the same row (#1720)', async () => { const tasks = { 'task-old': { id: 'task-old', status: 'working', ownerBotId: 'b1', title: 'Old', createdAt: 1, updatedAt: 2 }, 'task-new': { @@ -238,7 +238,7 @@ test('the Project board reads the typed fields from the same row (#1720)', () => } const rt = { state: { tasks }, readProject: id => ({ id }), projectOfMember: () => ({ id: 'p' }), taskVisible: () => true } installProjectBoard(rt) - const rows = rt.projectBoard('p') + const rows = (await rt.projectBoard('p')).rows const old = rows.find(row => row.id === 'task-old') const fresh = rows.find(row => row.id === 'task-new') assert.equal(old.taskClass, undefined, 'a pre-slice row keeps its old shape') diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index cff560c32..19322a9da 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -638,7 +638,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 +65,67 @@ 'Unread activity': '有未读', Search: '搜索', 'New chat': '新对话', @@ -706,7 +706,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 +140,21 @@ Duplicate: '复制一份', Hidden: '已隐藏', Admin: '管理员', @@ -728,7 +728,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,73 @@ const ZH = { +@@ -41,22 +170,73 @@ ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -805,7 +805,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 +273,59 @@ 'Connecting…': '正在连接…', 'Could not connect: {error}': '连接失败:{error}', 'Connected as {account}': '已连接:{account}', @@ -865,7 +865,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connected: '已连接', Disconnect: '断开', Connect: '连接', -@@ -105,17 +105,64 @@ const ZH = { +@@ -105,17 +338,64 @@ 'GitHub addresses must start with https://': 'GitHub 地址必须以 https:// 开头', 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', 'No such connector': '没有这个连接器', @@ -934,7 +934,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 +408,6 @@ New: '新', // New chat @@ -943,7 +943,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 +432,24 @@ Everyone: '所有人', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', @@ -968,7 +968,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 +465,16 @@ 'Close details': '关闭详情', 'Copied conversation': '已复制对话', 'Share conversation': '分享对话', @@ -985,7 +985,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 +518,25 @@ 'This model is not available now. Pick another one.': '这个模型现在不可用,请换一个。', 'Every message of this Bot runs on it.': '这个 Bot 的每条消息都用它。', 'Applies from the next message.': '从下一条消息开始生效。', @@ -1011,7 +1011,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 +560,7 @@ 'Kept above the other chats.': '排在其他对话上面。', 'Search still finds it.': '仍然可以搜索到。', Manage: '管理', @@ -1019,7 +1019,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 +597,8 @@ 'Harness default': 'Harness 默认', Saving: '保存中', 'Manage models': '管理模型', @@ -1028,7 +1028,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 +637,10 @@ 'No group chats yet.': '还没有群聊。', 'A group chat lets several Bots work on one thing with you.': '在群聊里,几个 Bot 和你一起做一件事。', 'Back to group chats': '返回群聊列表', @@ -1041,7 +1041,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 +650,6 @@ Pin: '置顶', Pinned: '已置顶', 'Mark as Unread': '标为未读', @@ -1049,7 +1049,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 +658,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', @@ -1058,7 +1058,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 +671,13 @@ '{n} d': '{n} 天', // Plugin updates @@ -1078,7 +1078,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 +687,75 @@ 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -1155,7 +1155,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 +783,13 @@ Reply: '回复', 'More message actions': '更多操作', More: '更多', @@ -1169,7 +1169,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 +799,9 @@ 'Replies from': '群聊回复:', 'Reply from': '收到回复:', 'Message from': '收到消息:', @@ -1179,7 +1179,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 +811,8 @@ 'Working out a plan': '理一下思路', 'Sorting out the details': '梳理细节', 'On it': '在做了', @@ -1188,7 +1188,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 +840,6 @@ 'Looking back through the chat': '翻看聊天记录', '{name} looked up its own chat': '{name} 查看了自己的对话', '{name} looked up its own chat for “{text}”': '{name} 在自己的对话里查找「{text}」', @@ -1196,7 +1196,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 +850,31 @@ 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', @@ -1229,7 +1229,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 +897,7 @@ Selected: '已选', Skip: '跳过', Submit: '提交', @@ -1237,7 +1237,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 +905,7 @@ 'Your answer': '你的回答', // Secrets @@ -1246,7 +1246,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 +921,6 @@ 'At least {count} characters.': '至少 {count} 个字符。', 'Replace value': '替换值', 'Saving…': '保存中…', @@ -1254,7 +1254,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 +947,7 @@ 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', 'What it is for': '用途', Value: '值', @@ -1263,7 +1263,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 +987,6 @@ Remove: '删除', 'Ask or update': '询问或更新', "Ask about or update {name}'s memory": '询问或更新 {name} 的记忆', @@ -1271,7 +1271,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 +1115,11 @@ 'Compared with the {count} days before': '与再往前 {count} 天相比', '{share}% of all usage from {span}': '占 {span} 全部用量的 {share}%', // About @@ -1286,7 +1286,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 +1129,13 @@ 'Update checks are off.': '已关闭更新检查。', 'Installed from a local folder; update it there.': '从本地文件夹安装,请在那里更新。', 'Updates are not offered for this install.': '这种安装方式不提供更新。', @@ -1300,7 +1300,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 +1151,7 @@ Show: '查看', Hide: '收起', 'GitHub issue': 'GitHub issue', @@ -1309,7 +1309,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 +1163,7 @@ 'Recent errors': '最近的错误', 'Recent errors · {count}': '最近的错误 · {count}', 'No errors recorded.': '没有错误记录。', @@ -1318,7 +1318,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 +1196,21 @@ 'Add at least one more Bot': '再至少添加一个 Bot', Members: '成员', // Connectors and scheduled tasks @@ -1340,7 +1340,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 +1218,21 @@ 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', @@ -1362,7 +1362,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 +1242,7 @@ minutes: '分钟', hours: '小时', days: '天', @@ -1370,7 +1370,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 +1255,20 @@ Unit: '单位', 'Create task': '创建任务', 'Turn on Automation tasks in DSH': '在 DSH 里打开「自动化任务」', @@ -1393,7 +1393,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 +1279,19 @@ 'Pick a date and time': '选一个日期和时间', 'Pick a time in the future': '选一个将来的时间', 'Pick a time of day': '选一个时间', @@ -1413,13 +1413,18 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Working folder': '工作文件夹', 'Working folder: {folder}': '工作文件夹:{folder}', Folder: '文件夹', -@@ -838,6 +838,13 @@ const ZH = { +@@ -838,6 +1307,18 @@ 'Up one level': '上一级', 'No folders inside.': '里面没有文件夹。', 'Some folders are not shown.': '还有一些文件夹没有显示。', + 'Project settings': '项目设置', + 'Project board': '项目看板', + 'No Project here.': '这里没有项目。', ++ // The record beside the cards (#1767): the folder was read, and what it holds. ++ Decisions: '决策', ++ Meetings: '会议', ++ 'Not decided': '未决定', ++ 'decision file gone': '决策文件已不在', + // The Project board names its columns with the same keys the task record uses. + 'not-started': '未开始', + 'in-progress': '进行中', @@ -1427,7 +1432,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 +1334,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 个字符'], @@ -1439,7 +1444,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 +1367,20 @@ 'Unknown Bot': '找不到这个 Bot', 'Unknown conversation': '找不到这个对话', 'A group chat needs at least two Bots': '群聊至少要两个 Bot', @@ -1460,7 +1465,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 +1403,8 @@ 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 3a3e55ac1..9e5860576 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -693,7 +693,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 @@ export function install(rt) { +@@ -50,12 +50,88 @@ } } const readOnly = () => { @@ -783,7 +783,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ export function install(rt) { +@@ -87,10 +87,32 @@ return undefined } if (parsed?.version === 1) { @@ -799,9 +799,10 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + if (parsed?.version === STATE_VERSION) { + const problem = ownershipProblem(parsed) + if (problem !== undefined) { @@ -809,15 +810,14 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + stuck = 'saved by a newer FlyBot; update FlyBot' return undefined } -@@ -104,6 +104,20 @@ export function install(rt) { +@@ -104,6 +104,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 @@ -838,7 +838,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 @@ export function install(rt) { +@@ -112,6 +112,11 @@ migrated = true } for (const bot of Object.values(state.bots)) { @@ -850,7 +850,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 @@ export function install(rt) { +@@ -120,6 +120,11 @@ bot.color = currentColor(bot.color) migrated = true } @@ -862,7 +862,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,369 @@ export function install(rt) { +@@ -161,12 +161,378 @@ migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1193,10 +1193,19 @@ 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): 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': return { tasks: rt.projectBoard?.(projectText(body.id, 200), actor) ?? [] } ++ // 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. ++ case 'project-board': { ++ const board = (await rt.projectBoard?.(projectText(body.id, 200), actor)) ?? [] ++ const rows = Array.isArray(board) ? board : (board.rows ?? []) ++ const sections = Array.isArray(board) ? {} : board ++ return { ++ 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' }, ++ } ++ } + default: throw projectRefusal(`Unknown endpoint ${endpoint}`, 'bot/not-found') + } + } @@ -1233,7 +1242,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 @@ export function install(rt) { +@@ -185,14 +185,24 @@ for (const room of stale) void rt.retitleRoom(room) })() @@ -1259,7 +1268,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ export function install(rt) { +@@ -235,11 +235,37 @@ return warnReadOnly() } @@ -1297,7 +1306,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 @@ export function install(rt) { +@@ -260,9 +260,10 @@ ensureLock() await rename(temp, statePath) seen = stamp() @@ -1309,7 +1318,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ export function install(rt) { +@@ -273,13 +273,37 @@ }) } @@ -1349,7 +1358,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 @@ export function install(rt) { +@@ -313,6 +313,13 @@ } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1363,7 +1372,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 @@ export function install(rt) { +@@ -328,6 +328,8 @@ delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1372,7 +1381,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 @@ export function install(rt) { +@@ -335,23 +335,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 9146eb26d..0b7c96f2f 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -42,6 +42,56 @@ module.exports = __toCommonJS(index_exports); +@@ -42,6 +42,56 @@ // src/client/i18n.js var NAMESPACE = "ds-bot"; var ZH = { @@ -60,7 +60,7 @@ diff --git a/client.js b/client.js // Shared "Main Bot": "主 Bot", Working: "工作中", -@@ -49,6 +49,67 @@ var ZH = { +@@ -49,6 +99,67 @@ "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -128,7 +128,7 @@ diff --git a/client.js b/client.js "New…": "新建", "Animation": "动画", "Bot animation": "Bot 动画", -@@ -63,6 +63,21 @@ var ZH = { +@@ -63,6 +174,21 @@ Duplicate: "复制一份", Hidden: "已隐藏", Admin: "管理员", @@ -150,7 +150,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,72 @@ var ZH = { +@@ -78,21 +204,72 @@ " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -226,7 +226,7 @@ diff --git a/client.js b/client.js Connectors: "连接器", "Search connectors": "搜索连接器", "No connectors": "暂无连接器", -@@ -129,6 +129,59 @@ var ZH = { +@@ -129,6 +306,59 @@ "Connecting…": "正在连接…", "Could not connect: {error}": "连接失败:{error}", "Connected as {account}": "已连接:{account}", @@ -286,7 +286,7 @@ diff --git a/client.js b/client.js Connected: "已连接", Disconnect: "断开", Connect: "连接", -@@ -141,17 +141,64 @@ var ZH = { +@@ -141,17 +371,64 @@ "GitHub addresses must start with https://": "GitHub 地址必须以 https:// 开头", "This DSH cannot load connectors": "这个 DSH 不能加载连接器", "No such connector": "没有这个连接器", @@ -355,7 +355,7 @@ diff --git a/client.js b/client.js // Header "Message {name}": "给 {name} 发消息", Message: "发消息", -@@ -162,8 +162,6 @@ var ZH = { +@@ -162,8 +439,6 @@ "Dismiss new messages": "关闭新消息提示", New: "新", // New chat @@ -364,7 +364,7 @@ diff --git a/client.js b/client.js "Add to group chat": "加入群聊", "Message Bot": "给 Bot 发消息", "To:": "发给:", -@@ -187,9 +187,24 @@ var ZH = { +@@ -187,9 +462,24 @@ Everyone: "所有人", "Every member answers in turn, admin first.": "每位成员轮流回答,管理员先说。", "Members · {count}": "成员 · {count}", @@ -389,7 +389,7 @@ diff --git a/client.js b/client.js "Remove {name} from the group": "把 {name} 移出群聊", "Remove from group": "移出群聊", "Add member": "添加成员", -@@ -205,6 +205,16 @@ var ZH = { +@@ -205,6 +495,16 @@ "Close details": "关闭详情", "Copied conversation": "已复制对话", "Share conversation": "分享对话", @@ -406,7 +406,7 @@ diff --git a/client.js b/client.js "Copy conversation as Markdown": "把对话复制为 Markdown", "This conversation is not part of your Bot team.": "这个对话不属于你的 Bot 团队。", "Admin {name}": "管理员 {name}", -@@ -247,6 +247,25 @@ var ZH = { +@@ -247,6 +547,25 @@ "This model is not available now. Pick another one.": "这个模型现在不可用,请换一个。", "Every message of this Bot runs on it.": "这个 Bot 的每条消息都用它。", "Applies from the next message.": "从下一条消息开始生效。", @@ -432,7 +432,7 @@ diff --git a/client.js b/client.js Profile: "资料", Label: "标签", "Optional. Shown under the name.": "可选,显示在名字下方。", -@@ -270,6 +270,7 @@ var ZH = { +@@ -270,6 +589,7 @@ "Kept above the other chats.": "排在其他对话上面。", "Search still finds it.": "仍然可以搜索到。", Manage: "管理", @@ -440,7 +440,7 @@ diff --git a/client.js b/client.js // Group chat settings "Leave it empty to name the group after its members.": "留空就按成员命名。", "Every member reads it before speaking in this group.": "每位成员在这个群里发言前都会读到。", -@@ -303,6 +303,8 @@ var ZH = { +@@ -303,6 +623,8 @@ "Harness default": "Harness 默认", Saving: "保存中", "Manage models": "管理模型", @@ -449,7 +449,7 @@ diff --git a/client.js b/client.js // Shapes Whale: "鲸鱼", Star: "星星", -@@ -374,9 +374,10 @@ var ZH = { +@@ -374,9 +696,10 @@ "No group chats yet.": "还没有群聊。", "A group chat lets several Bots work on one thing with you.": "在群聊里,几个 Bot 和你一起做一件事。", "Back to group chats": "返回群聊列表", @@ -462,7 +462,7 @@ diff --git a/client.js b/client.js "Harness settings": "Harness 设置", "Back to Bots": "返回 Bot 列表", "Close settings": "关闭设置", -@@ -385,7 +385,6 @@ var ZH = { +@@ -385,7 +708,6 @@ Pin: "置顶", Pinned: "已置顶", "Mark as Unread": "标为未读", @@ -470,7 +470,7 @@ diff --git a/client.js b/client.js "Group settings": "群设置", "Rename group": "重命名群聊", "Copy conversation ID": "复制对话 ID", -@@ -394,6 +394,8 @@ var ZH = { +@@ -394,6 +716,8 @@ // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. "Add DSH Agent": "添加 DSH Agent", @@ -479,7 +479,7 @@ diff --git a/client.js b/client.js "Remove DSH Agent": "移除 DSH Agent", "New session": "新会话", Archive: "归档", -@@ -404,13 +404,13 @@ var ZH = { +@@ -404,13 +728,13 @@ "{n} h": "{n} 小时", "{n} d": "{n} 天", // Plugin updates @@ -499,7 +499,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,74 @@ var ZH = { +@@ -420,8 +744,74 @@ "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -575,7 +575,7 @@ diff --git a/client.js b/client.js // Command palette Messages: "消息", Commands: "命令", -@@ -446,10 +446,13 @@ var ZH = { +@@ -446,10 +836,13 @@ Reply: "回复", "More message actions": "更多操作", More: "更多", @@ -589,7 +589,7 @@ diff --git a/client.js b/client.js "Earlier messages": "更早的消息", "Loading earlier messages…": "正在加载更早的消息…", "Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试", -@@ -459,6 +459,9 @@ var ZH = { +@@ -459,6 +852,9 @@ "Replies from": "群聊回复:", "Reply from": "收到回复:", "Message from": "收到消息:", @@ -599,7 +599,7 @@ diff --git a/client.js b/client.js "Stopped by": "叫停者:", "{count} messages with": "{count} 条消息,来自", "Routine started": "例行任务开始", -@@ -468,6 +468,8 @@ var ZH = { +@@ -468,6 +864,8 @@ "Working out a plan": "理一下思路", "Sorting out the details": "梳理细节", "On it": "在做了", @@ -608,7 +608,7 @@ diff --git a/client.js b/client.js "Busy with it": "正在处理", "Moving ahead": "推进中", "Taking care of it": "处理中", -@@ -495,7 +495,6 @@ var ZH = { +@@ -495,7 +893,6 @@ "Looking back through the chat": "翻看聊天记录", "{name} looked up its own chat": "{name} 查看了自己的对话", "{name} looked up its own chat for “{text}”": "{name} 在自己的对话里查找「{text}」", @@ -616,7 +616,7 @@ diff --git a/client.js b/client.js "{name} didn't answer in time": "{name} 没有及时回应", "{name} paused for your approval": "{name} 停下来等你批准", "{name} started on it": "{name} 开始做了", -@@ -506,12 +506,31 @@ var ZH = { +@@ -506,12 +903,31 @@ "Asked to make the tool call it wrote as text": "工具调用被写成了文字,已让它重新调用", "A Bot": "一个 Bot", "{name} is waiting for your approval": "{name} 等你批准", @@ -649,7 +649,7 @@ diff --git a/client.js b/client.js "Couldn't message {name}": "没能给 {name} 发消息", Messaged: "已发消息给", "Couldn't stop {name}": "没能叫停 {name}", -@@ -533,13 +533,14 @@ var ZH = { +@@ -533,13 +949,14 @@ Selected: "已选", Skip: "跳过", Submit: "提交", @@ -665,7 +665,7 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", -@@ -555,7 +555,6 @@ var ZH = { +@@ -555,7 +972,6 @@ "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", @@ -673,7 +673,7 @@ diff --git a/client.js b/client.js "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", -@@ -582,7 +582,7 @@ var ZH = { +@@ -582,7 +998,7 @@ "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", Value: "值", @@ -682,7 +682,7 @@ diff --git a/client.js b/client.js "Save key": "保存密钥", "Keys your Bots asked for. Bots never see the values: they use them as ": "你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ", " in their shell, and every value is replaced by ": " 使用,而且发给模型之前,每个值都会被替换成 ", -@@ -621,7 +621,6 @@ var ZH = { +@@ -621,7 +1037,6 @@ Remove: "删除", "Ask or update": "询问或更新", "Ask about or update {name}'s memory": "询问或更新 {name} 的记忆", @@ -690,7 +690,7 @@ diff --git a/client.js b/client.js "Added: {text}": "已添加:{text}", "Added to team memory: {text}": "已加入团队记忆:{text}", "Updated: {text}": "已更新:{text}", -@@ -747,11 +747,11 @@ var ZH = { +@@ -747,11 +1162,11 @@ "Compared with the {count} days before": "与再往前 {count} 天相比", "{share}% of all usage from {span}": "占 {span} 全部用量的 {share}%", // About @@ -705,7 +705,7 @@ diff --git a/client.js b/client.js Updates: "更新", "Check for updates": "检查更新", "Checking…": "正在检查…", -@@ -761,6 +761,13 @@ var ZH = { +@@ -761,6 +1176,13 @@ "Update checks are off.": "已关闭更新检查。", "Installed from a local folder; update it there.": "从本地文件夹安装,请在那里更新。", "Updates are not offered for this install.": "这种安装方式不提供更新。", @@ -719,7 +719,7 @@ diff --git a/client.js b/client.js Links: "链接", "Source code": "源代码", "Release notes": "发布说明", -@@ -776,7 +776,7 @@ var ZH = { +@@ -776,7 +1198,7 @@ Show: "查看", Hide: "收起", "GitHub issue": "GitHub issue", @@ -728,7 +728,7 @@ diff --git a/client.js b/client.js "Report on GitHub": "在 GitHub 上报告", "Copied the diagnostics.": "已复制诊断信息。", "Could not copy. Select the text below and copy it.": "无法复制。请选中下面的文字手动复制。", -@@ -788,7 +788,7 @@ var ZH = { +@@ -788,7 +1210,7 @@ "Recent errors": "最近的错误", "Recent errors · {count}": "最近的错误 · {count}", "No errors recorded.": "没有错误记录。", @@ -737,7 +737,7 @@ diff --git a/client.js b/client.js "Model call": "模型调用", "This window": "这个窗口", "Error log": "错误日志", -@@ -821,12 +821,21 @@ var ZH = { +@@ -821,12 +1243,21 @@ "Add at least one more Bot": "再至少添加一个 Bot", Members: "成员", // Connectors and scheduled tasks @@ -759,7 +759,7 @@ diff --git a/client.js b/client.js "Once, {time}": "一次,{time}", "Every day at {time}": "每天 {time}", "Every {days} at {time}": "每{days} {time}", -@@ -834,7 +834,21 @@ var ZH = { +@@ -834,7 +1265,21 @@ "Every weekend at {time}": "每个周末 {time}", "On the schedule {expression}": "按计划 {expression}", "Next: {time}": "下次 {time}", @@ -781,7 +781,7 @@ diff --git a/client.js b/client.js "in {name}": "在 {name}", "Click again to delete": "再点一次删除", "Delete task": "删除任务", -@@ -844,6 +844,7 @@ var ZH = { +@@ -844,6 +1289,7 @@ minutes: "分钟", hours: "小时", days: "天", @@ -789,7 +789,7 @@ diff --git a/client.js b/client.js "Morning briefing": "早间简报", "What to do": "要做什么", "Sum up what changed since yesterday and what needs me today.": "总结一下昨天以来的变化,以及今天需要我处理的事。", -@@ -856,9 +856,20 @@ var ZH = { +@@ -856,9 +1302,20 @@ Unit: "单位", "Create task": "创建任务", "Turn on Automation tasks in DSH": "在 DSH 里打开「自动化任务」", @@ -812,7 +812,7 @@ diff --git a/client.js b/client.js "No scheduled tasks yet.": "还没有定时任务。", "No scheduled tasks yet. Add one": "还没有定时任务,去添加", "Automation tasks are off in DSH": "DSH 的「自动化任务」没有打开", -@@ -869,10 +869,19 @@ var ZH = { +@@ -869,10 +1326,19 @@ "Pick a date and time": "选一个日期和时间", "Pick a time in the future": "选一个将来的时间", "Pick a time of day": "选一个时间", @@ -832,7 +832,7 @@ diff --git a/client.js b/client.js "Working folder": "工作文件夹", "Working folder: {folder}": "工作文件夹:{folder}", Folder: "文件夹", -@@ -887,7 +887,14 @@ var ZH = { +@@ -887,7 +1353,19 @@ "Use this folder": "使用这个文件夹", "Up one level": "上一级", "No folders inside.": "里面没有文件夹。", @@ -841,6 +841,11 @@ diff --git a/client.js b/client.js + "Project settings": "项目设置", + "Project board": "项目看板", + "No Project here.": "这里没有项目。", ++ // The record beside the cards (#1767): the folder was read, and what it holds. ++ Decisions: "决策", ++ Meetings: "会议", ++ "Not decided": "未决定", ++ "decision file gone": "决策文件已不在", + // The Project board names its columns with the same keys the task record uses. + "not-started": "未开始", + "in-progress": "进行中", @@ -848,7 +853,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 +1378,11 @@ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, "只有你、主 Bot 或「$1」的管理员可以修改"], [/^A key must have at least (\d+) characters$/, "密钥至少 $1 个字符"], [/^A key can have at most (\d+) characters$/, "密钥最多 $1 个字符"], @@ -860,7 +865,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 +1411,20 @@ "Unknown Bot": "找不到这个 Bot", "Unknown conversation": "找不到这个对话", "A group chat needs at least two Bots": "群聊至少要两个 Bot", @@ -881,7 +886,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 +1446,12 @@ "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -896,7 +901,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 +1502,23 @@ } const message = String(error?.message ?? error ?? "error").slice(0, 300); const code = typeof error?.code === "string" ? error.code : void 0; @@ -923,7 +928,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 +1547,21 @@ }; function failureLabel(code, message) { if (FAILURES[code]) return t(FAILURES[code]); @@ -951,7 +956,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 +1576,16 @@ var call = async (endpoint, payload, signal) => { let response; try { @@ -969,7 +974,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 +1603,7 @@ if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); @@ -978,7 +983,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 +1613,388 @@ return result.value; }; @@ -1367,7 +1372,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 +2058,9 @@ var CHARACTER_COLORS = ["black", "brown", "red", "orange", "yellow", "green", "cyan", "blue", "violet", "magenta", "gray", "aurora"]; var DEFAULT_COLORS = CHARACTER_COLORS.filter((color) => !["black", "gray", "brown"].includes(color) && !GRADIENTS[color]); var isColor = (color) => isHex(color) || Object.hasOwn(INKS, color); @@ -1379,7 +1384,7 @@ diff --git a/client.js b/client.js return value >>> 0; } function defaultColor(id) { -@@ -1177,19 +1177,96 @@ function defaultShape(id) { +@@ -1177,19 +2075,96 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1479,7 +1484,7 @@ diff --git a/client.js b/client.js ``, `` ].join(""); -@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { +@@ -1206,8 +2181,8 @@ return ""; } } @@ -1490,7 +1495,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 +2217,46 @@ } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1634,16 +1639,7 @@ diff --git a/client.js b/client.js - else delete SHAPES[id]; - refresh(); - }; -+// src/client/cluster-order.js -+function clusterOrder(people2) { -+ const list = Array.isArray(people2) ? people2 : []; -+ const remote = (bot) => Boolean(bot?.remote); -+ if (!list.some(remote)) return list; -+ const locals = list.filter((bot) => !remote(bot)); -+ const remotes = list.filter(remote); -+ if (list.length <= 4) return [...remotes, ...locals]; -+ return [...locals, ...remotes]; - } +-} -function registerTheme(id, theme) { - if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); - for (const mode of ["light", "dark"]) { @@ -1657,7 +1653,16 @@ diff --git a/client.js b/client.js - else delete customThemes[id]; - registry.set((value) => value + 1); - }; --} ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } -var allThemes = () => ({ ...THEMES, ...customThemes }); // src/shared/mentions.js @@ -1702,15 +1707,10 @@ 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 +2274,86 @@ } return spans; } -- --// src/client/text.js --var MENTION_ORIGIN = "https://bots.local/"; --function linkMentions(text, bots, selfId) { -- if (!text || !text.includes("@")) return text; +function unmatchedMentions(text2, bots, { references = [] } = {}) { + const value = String(text2 ?? ""); + const skip = [ @@ -1758,9 +1758,11 @@ diff --git a/client.js b/client.js + } + return stripFence(out).replace(/\s+/g, " ").trim(); +} -+ -+// src/client/text.js -+var MENTION_ORIGIN = "https://bots.local/"; + + // src/client/text.js + var MENTION_ORIGIN = "https://bots.local/"; +-function linkMentions(text, bots, selfId) { +- if (!text || !text.includes("@")) return text; +function linkMentions(text2, bots, selfId) { + if (!text2 || !text2.includes("@")) return text2; + const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); @@ -1797,7 +1799,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 +2374,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; @@ -1819,7 +1821,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 +2393,121 @@ return []; } } @@ -1948,7 +1950,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,16 +2580,45 @@ } return { count, peerIds }; } @@ -1973,7 +1975,7 @@ diff --git a/client.js b/client.js + return readableLine(text2, names); } return ""; - } ++} +function readableLine(text2, names = []) { + return dropKnownTrace(collapsedLine(text2), names); +} @@ -1994,69 +1996,19 @@ diff --git a/client.js b/client.js +var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); +function speechOf(text2, names = []) { + return readableLine(text2, names).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); -+} + } 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,201 +1624,1174 @@ function usageOf(list, ids) { +@@ -1624,9 +2636,982 @@ const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } -- --// src/client/motion.js --var import_react = require("react"); --var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; --function springEasing(stiffness, damping) { -- const w0 = Math.sqrt(stiffness); -- const zeta = damping / (2 * w0); -- const at = (t2) => { -- if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -- const wd = w0 * Math.sqrt(1 - zeta * zeta); -- return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -- }; -- let end = 0.05; -- while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -- const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -- return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; +function faceRowTitle(title) { + const text2 = String(title ?? ""); + const named = text2.match(/^Face-to-face · (.+) · (\d{6})$/); + if (named) return `${named[2]} · ${named[1]}`; + return text2.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text2; - } --var SPRING_SHAPE = springEasing(400, 31.2); --var SPRING_TAP = springEasing(900, 48); --var SPRING_LIST = springEasing(484, 35.2); --var SPRING_STRETCH = springEasing(320, 20); --var SPRING_POP = springEasing(500, 22); --var SPRING_LEAD = springEasing(784, 48.2); --var SPRING_TRAIL = springEasing(289, 29.2); --var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; --var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); --function liveSprings(apply2) { -- const values = /* @__PURE__ */ new Map(); -- let frame = 0; -- let last = 0; -- const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -- const step = (now) => { -- const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -- last = now; -- const steps = Math.ceil(dt * 240); -- const h34 = dt / steps; -- let moving = false; -- for (const value of values.values()) { -- for (let index = 0; index < steps; index += 1) { -- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; -- value.x += value.v * h34; -- } -- if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { -- value.x = value.to; -- value.v = 0; -- } else moving = true; -- } -- apply2(read()); -- frame = moving ? requestAnimationFrame(step) : 0; -- }; ++} +function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel; + if (!ref) return void 0; @@ -2076,79 +2028,22 @@ diff --git a/client.js b/client.js +function createSource(initial) { + let value = initial; + const listeners2 = /* @__PURE__ */ new Set(); - return { -- target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, -- set(targets, curves, jump) { -- for (const [key, to] of Object.entries(targets)) { -- const value = values.get(key); -- const { stiffness: k, damping: c } = curves[key]; -- if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); -- else Object.assign(value, { to, k, c }); -- } -- if (jump) { -- cancelAnimationFrame(frame); -- frame = 0; -- apply2(read()); -- return; -- } -- if (!frame) { -- last = performance.now(); -- frame = requestAnimationFrame(step); -- } ++ return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners2.add(listener); + return () => { + listeners2.delete(listener); + }; - }, -- stop: () => cancelAnimationFrame(frame) ++ }, + set(next) { + const resolved = typeof next === "function" ? next(value) : next; + if (resolved === value) return; + value = resolved; + for (const listener of [...listeners2]) listener(); + } - }; - } --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 } = {}) { -- const ownBox = (0, import_react.useRef)(null); -- const box = given ?? ownBox; -- const thumb = (0, import_react.useRef)(null); -- const own = (0, import_react.useRef)(null); -- if (own.current === null) { -- own.current = { -- key: void 0, -- node: null, -- observer: null, -- springs: liveSprings(({ start, end }) => { -- const knob = thumb.current; -- if (!knob) return; -- knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; -- knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; -- }) -- }; -- } -- const place = (jump) => { -- const container = box.current; -- const knob = thumb.current; -- if (!container || !knob) return; -- const chosen = container.querySelector(selector); -- if (!chosen) { -- knob.dataset.hidden = ""; -- return; -- } -- const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; -- const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); -- const { springs } = own.current; -- const before = springs.target(); -- const hidden = knob.dataset.hidden !== void 0; -- if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; -- delete knob.dataset.hidden; -- const forward = before === null || start >= before.start; -- springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); -- container.dataset.glide = ""; ++ }; ++} +var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +function indexRoster(value) { @@ -2161,26 +2056,7 @@ diff --git a/client.js b/client.js + if (prev && prev !== bot && !prev.remote && bot?.remote) return; + if (prev && prev !== bot && prev.id === id) return; + byId[id] = bot; - }; -- (0, import_react.useLayoutEffect)(() => { -- const state = own.current; -- const node = box.current; -- if (node === state.node && key === state.key) return; -- const fresh = node !== state.node; -- state.key = key; -- if (fresh) { -- state.observer?.disconnect(); -- state.node = node; -- state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; -- state.observer?.observe(node); -- } -- place(fresh); -- }); -- (0, import_react.useEffect)(() => () => { -- own.current.observer?.disconnect(); -- own.current.springs.stop(); -- }, []); -- return { box, thumb }; ++ }; + for (const bot of value.bots) { + for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); + } @@ -2193,74 +2069,20 @@ diff --git a/client.js b/client.js + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; - } --function Segmented({ label, value, className, children }) { -- const glide = useGlide(value); -- return (0, import_react.createElement)( -- "div", -- { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, -- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- children -- ); ++} +function rosterBot(roster, id) { + if (id === void 0 || id === null || id === "") return void 0; + const list = roster?.bots ?? []; + const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); + if (own) return own; + return roster?.byId?.[id]; - } --function useFlip(list, key, selector, id = (node) => node.dataset.flip) { -- const tops = (0, import_react.useRef)(null); -- (0, import_react.useLayoutEffect)(() => { -- const container = list.current; -- if (!container) return; -- const next = /* @__PURE__ */ new Map(); -- const moving = tops.current !== null && !reducedMotion(); -- for (const node of container.querySelectorAll(selector)) { -- const name = id(node); -- const top = node.offsetTop; -- next.set(name, top); -- if (!moving) continue; -- const before = tops.current.get(name); -- if (before === void 0) { -- node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); -- } else if (Math.abs(before - top) > 0.5) { -- node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); -- } -- } -- tops.current = next; -- }, [key]); ++} +function botByName(bots, name) { + const wanted = String(name ?? "").toLowerCase(); + if (wanted === "") return void 0; + const list = Array.isArray(bots) ? bots : []; + return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); - } --function useMorph(box, key) { -- const last = (0, import_react.useRef)({ key, height: null }); -- (0, import_react.useLayoutEffect)(() => { -- const node = box.current; -- const state = last.current; -- if (!node) { -- last.current = { key, height: null }; -- return; -- } -- const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); -- if (key === state.key) { -- if (running.length === 0) state.height = node.offsetHeight; -- return; -- } -- const from = running.length ? node.offsetHeight : state.height; -- for (const animation2 of running) animation2.cancel(); -- const to = node.offsetHeight; -- last.current = { key, height: to }; -- if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; -- node.dataset.morph = ""; -- const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); -- animation.onfinish = () => { -- delete node.dataset.morph; -- }; -- animation.oncancel = () => { ++} +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -3154,196 +2976,94 @@ diff --git a/client.js b/client.js + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); +} -+ -+// src/client/motion.js + + // src/client/motion.js +-var import_react = require("react"); +var import_react4 = require("react"); -+var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; -+function springEasing(stiffness, damping) { -+ const w0 = Math.sqrt(stiffness); -+ const zeta = damping / (2 * w0); -+ const at = (t2) => { -+ if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -+ const wd = w0 * Math.sqrt(1 - zeta * zeta); -+ return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -+ }; -+ let end = 0.05; -+ while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -+ const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -+ return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; -+} -+var SPRING_SHAPE = springEasing(400, 31.2); -+var SPRING_TAP = springEasing(900, 48); -+var SPRING_LIST = springEasing(484, 35.2); -+var SPRING_STRETCH = springEasing(320, 20); -+var SPRING_POP = springEasing(500, 22); -+var SPRING_LEAD = springEasing(784, 48.2); -+var SPRING_TRAIL = springEasing(289, 29.2); -+var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; -+var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); -+function liveSprings(apply2) { -+ const values = /* @__PURE__ */ new Map(); -+ let frame = 0; -+ let last = 0; -+ const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -+ const step = (now) => { -+ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -+ last = now; -+ const steps = Math.ceil(dt * 240); + var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; + function springEasing(stiffness, damping) { + const w0 = Math.sqrt(stiffness); +@@ -1659,12 +3644,12 @@ + const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); + last = now; + const steps = Math.ceil(dt * 240); +- const h34 = dt / steps; + const h49 = dt / steps; -+ let moving = false; -+ for (const value of values.values()) { -+ for (let index = 0; index < steps; index += 1) { + let moving = false; + for (const value of values.values()) { + for (let index = 0; index < steps; index += 1) { +- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; +- value.x += value.v * h34; + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h49; + value.x += value.v * h49; -+ } -+ if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { -+ value.x = value.to; -+ value.v = 0; -+ } else moving = true; -+ } -+ apply2(read()); -+ frame = moving ? requestAnimationFrame(step) : 0; -+ }; -+ return { -+ target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, -+ set(targets, curves, jump) { -+ for (const [key, to] of Object.entries(targets)) { -+ const value = values.get(key); -+ const { stiffness: k, damping: c } = curves[key]; -+ if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); -+ else Object.assign(value, { to, k, c }); -+ } -+ if (jump) { -+ cancelAnimationFrame(frame); -+ frame = 0; -+ apply2(read()); -+ return; -+ } -+ if (!frame) { -+ last = performance.now(); -+ frame = requestAnimationFrame(step); -+ } -+ }, -+ stop: () => cancelAnimationFrame(frame) -+ }; -+} -+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 } = {}) { + } + if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { + value.x = value.to; +@@ -1699,10 +3684,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 } = {}) { +- const ownBox = (0, import_react.useRef)(null); + const ownBox = (0, import_react4.useRef)(null); -+ const box = given ?? ownBox; + const box = given ?? ownBox; +- const thumb = (0, import_react.useRef)(null); +- const own = (0, import_react.useRef)(null); + const thumb = (0, import_react4.useRef)(null); + const own = (0, import_react4.useRef)(null); -+ if (own.current === null) { -+ own.current = { -+ key: void 0, -+ node: null, -+ observer: null, -+ springs: liveSprings(({ start, end }) => { -+ const knob = thumb.current; -+ if (!knob) return; -+ knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; -+ knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; -+ }) -+ }; -+ } -+ const place = (jump) => { -+ const container = box.current; -+ const knob = thumb.current; -+ if (!container || !knob) return; -+ const chosen = container.querySelector(selector); -+ if (!chosen) { -+ knob.dataset.hidden = ""; -+ return; -+ } -+ const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; -+ const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); -+ const { springs } = own.current; -+ const before = springs.target(); -+ const hidden = knob.dataset.hidden !== void 0; -+ if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; -+ delete knob.dataset.hidden; -+ const forward = before === null || start >= before.start; -+ springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); -+ container.dataset.glide = ""; -+ }; + if (own.current === null) { + own.current = { + key: void 0, +@@ -1736,7 +3721,7 @@ + springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); + container.dataset.glide = ""; + }; +- (0, import_react.useLayoutEffect)(() => { + (0, import_react4.useLayoutEffect)(() => { -+ const state = own.current; -+ const node = box.current; -+ if (node === state.node && key === state.key) return; -+ const fresh = node !== state.node; -+ state.key = key; -+ if (fresh) { -+ state.observer?.disconnect(); -+ state.node = node; -+ state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; -+ state.observer?.observe(node); -+ } -+ place(fresh); -+ }); + const state = own.current; + const node = box.current; + if (node === state.node && key === state.key) return; +@@ -1750,7 +3735,7 @@ + } + place(fresh); + }); +- (0, import_react.useEffect)(() => () => { + (0, import_react4.useEffect)(() => () => { -+ own.current.observer?.disconnect(); -+ own.current.springs.stop(); -+ }, []); -+ return { box, thumb }; -+} -+function Segmented({ label, value, className, children }) { -+ const glide = useGlide(value); + own.current.observer?.disconnect(); + own.current.springs.stop(); + }, []); +@@ -1758,16 +3743,16 @@ + } + function Segmented({ label, value, className, children }) { + const glide = useGlide(value); +- return (0, import_react.createElement)( + return (0, import_react4.createElement)( -+ "div", -+ { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, + "div", + { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, +- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + (0, import_react4.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ children -+ ); -+} -+function useFlip(list, key, selector, id = (node) => node.dataset.flip) { + children + ); + } + function useFlip(list, key, selector, id = (node) => node.dataset.flip) { +- const tops = (0, import_react.useRef)(null); +- (0, import_react.useLayoutEffect)(() => { + const tops = (0, import_react4.useRef)(null); + (0, import_react4.useLayoutEffect)(() => { -+ const container = list.current; -+ if (!container) return; -+ const next = /* @__PURE__ */ new Map(); -+ const moving = tops.current !== null && !reducedMotion(); -+ for (const node of container.querySelectorAll(selector)) { -+ const name = id(node); -+ const top = node.offsetTop; -+ next.set(name, top); -+ if (!moving) continue; -+ const before = tops.current.get(name); -+ if (before === void 0) { -+ node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); -+ } else if (Math.abs(before - top) > 0.5) { -+ node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); -+ } -+ } -+ tops.current = next; -+ }, [key]); -+} -+function useMorph(box, key) { + const container = list.current; + if (!container) return; + const next = /* @__PURE__ */ new Map(); +@@ -1788,8 +3773,8 @@ + }, [key]); + } + function useMorph(box, key) { +- const last = (0, import_react.useRef)({ key, height: null }); +- (0, import_react.useLayoutEffect)(() => { + const last = (0, import_react4.useRef)({ key, height: null }); + (0, import_react4.useLayoutEffect)(() => { -+ const node = box.current; -+ const state = last.current; -+ if (!node) { -+ last.current = { key, height: null }; -+ return; -+ } -+ const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); -+ if (key === state.key) { -+ if (running.length === 0) state.height = node.offsetHeight; -+ return; -+ } -+ const from = running.length ? node.offsetHeight : state.height; -+ for (const animation2 of running) animation2.cancel(); -+ const to = node.offsetHeight; -+ last.current = { key, height: to }; -+ if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; -+ node.dataset.morph = ""; -+ const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); -+ animation.onfinish = () => { -+ delete node.dataset.morph; -+ }; -+ animation.oncancel = () => { - if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; - }; + const node = box.current; + const state = last.current; + if (!node) { +@@ -1817,8 +3802,8 @@ }); } function useLinger(value, ms) { @@ -3354,7 +3074,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 +3823,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)} @@ -3362,7 +3082,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 +3842,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} @@ -3377,7 +3097,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 +3927,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} @@ -3403,7 +3123,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 +3952,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} @@ -3440,7 +3160,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 +3999,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} @@ -3448,7 +3168,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 +4093,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} @@ -3456,7 +3176,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 +4107,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} @@ -3467,7 +3187,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 +4138,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} @@ -3478,7 +3198,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 +4165,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} @@ -3492,7 +3212,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 +4178,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} @@ -3501,7 +3221,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 +4214,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)} @@ -3510,7 +3230,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 +4254,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. */ @@ -3535,7 +3255,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 +4286,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} @@ -3543,7 +3263,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 +4320,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} @@ -3607,7 +3327,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 +4390,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} @@ -3625,7 +3345,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 +4452,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} @@ -3634,7 +3354,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 +4471,7 @@ .bt-you-wrap{display:inline-flex;padding:0} .bt-connect-foot{width:100%;height:36px} @@ -3643,7 +3363,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 +4481,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} @@ -3652,7 +3372,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 +4518,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}} @@ -3662,7 +3382,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 +4586,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} @@ -3671,7 +3391,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 +4689,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%)} @@ -3694,7 +3414,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 +4715,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} @@ -3702,7 +3422,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 +4724,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} @@ -3759,7 +3479,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 +4775,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} @@ -3779,7 +3499,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 +4819,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} @@ -3789,7 +3509,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 +4832,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} @@ -3798,7 +3518,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 +4865,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} @@ -3808,7 +3528,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 +4921,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} @@ -3816,7 +3536,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 +4930,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)} `; @@ -3829,78 +3549,130 @@ diff --git a/client.js b/client.js document.head.appendChild(style); return style; } -@@ -2805,1975 +2805,4195 @@ function sheetClassNames(sheet) { - visit(sheet.cssRules); - } catch { +@@ -2807,9 +4953,9 @@ } -- return names; -+ return names; -+} + return names; + } +-function compileFragmentSelectors(text, names) { +function compileFragmentSelectors(text2, names) { -+ const compiled = /* @__PURE__ */ new Map(); + const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { + return text2.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -+ if (!compiled.has(fragment)) { -+ const exact = []; -+ for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -+ compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -+ } -+ return compiled.get(fragment); -+ }); -+} + if (!compiled.has(fragment)) { + const exact = []; + for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +@@ -2818,7 +4964,7 @@ + return compiled.get(fragment); + }); + } +-function installCompiledStyles(text, tag) { +function installCompiledStyles(text2, tag) { -+ const style = installStyles("", tag); -+ const cache = /* @__PURE__ */ new WeakMap(); -+ const render = () => { -+ const names = /* @__PURE__ */ new Set(); -+ for (const sheet of document.styleSheets) { -+ if (sheet.ownerNode?.dataset?.dshBot) continue; -+ let list = cache.get(sheet); -+ if (!list) { -+ list = sheetClassNames(sheet); -+ cache.set(sheet, list); -+ } -+ for (const name of list) names.add(name); -+ } + const style = installStyles("", tag); + const cache = /* @__PURE__ */ new WeakMap(); + const render = () => { +@@ -2832,7 +4978,7 @@ + } + for (const name of list) names.add(name); + } +- const next = compileFragmentSelectors(text, names); + const next = compileFragmentSelectors(text2, names); -+ if (style.textContent !== next) style.textContent = next; -+ }; -+ render(); -+ let timer; -+ const schedule = () => { -+ clearTimeout(timer); -+ timer = setTimeout(render, 120); -+ }; -+ const onLoad = (event) => { -+ if (event.target instanceof HTMLLinkElement) schedule(); -+ }; -+ const observer = new MutationObserver(schedule); -+ observer.observe(document.head, { childList: true }); -+ document.addEventListener("load", onLoad, true); -+ return () => { -+ observer.disconnect(); -+ document.removeEventListener("load", onLoad, true); -+ clearTimeout(timer); -+ style.remove(); -+ }; -+} -+ + if (style.textContent !== next) style.textContent = next; + }; + render(); +@@ -2855,226 +5001,204 @@ + }; + } + +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +- }; +- }, [enabled]); +// 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"; -+} + } +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +- }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +function 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 StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +- ); ++function isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +function ownersFromMembers(members) { + const rows = []; + const seen = /* @__PURE__ */ new Set(); @@ -3914,7 +3686,13 @@ diff --git a/client.js b/client.js + } + rows.sort((a, b) => a.sort.localeCompare(b.sort)); + return rows.slice(0, 3).map((row) => row.owner); -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- ); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -3927,17 +3705,53 @@ diff --git a/client.js b/client.js + } + out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return out.slice(0, 3); -+} + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +function joinCodeOf(code) { + return String(code ?? "").replace(/\D/g, "").slice(0, 6); -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +- ); +var FACE_JOIN_WINDOW_MS = 10 * 6e4; +function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { + if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; + const deadline = Date.parse(expiresAt); + if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); + return startedAt || void 0; -+} + } +-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 formatFaceRoomTitle({ name, owners, members, code, at } = {}) { + const named = String(name ?? "").trim(); + if (named && !isAutoFaceTitle(named)) return named; @@ -3970,12 +3784,26 @@ 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 () => { + }; @@ -4005,7 +3833,25 @@ 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); @@ -4019,116 +3865,135 @@ diff --git a/client.js b/client.js + observer?.disconnect(); + link.remove(); + }; -+} -+ -+// src/client/icons.js + } + + // src/client/icons.js +-var import_react3 = require("react"); +var import_react5 = require("react"); -+function PlusIcon() { + function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + return (0, import_react5.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react5.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+} -+function SearchIcon() { + } + function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + return (0, import_react5.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react5.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react5.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+} -+function PeopleIcon() { + } + function PeopleIcon() { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) + (0, import_react5.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), + (0, import_react5.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), + (0, import_react5.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -+ ); -+} -+function ArrowUpIcon() { + ); + } + function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); + return (0, import_react5.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react5.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); -+} -+function MicIcon({ size = 16 }) { + } + function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, + "svg", + { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) + (0, import_react5.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), + (0, import_react5.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -+ ); -+} -+function WaveIcon({ size = 16 }) { + ); + } + function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + "svg", + { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) + [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react5.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -+ ); -+} -+function ShareIcon() { + ); + } + function ShareIcon() { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) + (0, import_react5.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) -+ ); -+} -+function CloseIcon() { + ); + } + function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + return (0, import_react5.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react5.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+} + } +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +var ChevronLeftIcon = () => (0, import_react5.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react5.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +var ChevronRightIcon = () => (0, import_react5.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react5.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); -+var lineIcon = (paths) => function LineIcon() { + var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) + paths.map((d, index) => (0, import_react5.createElement)("path", { key: index, d })) -+ ); -+}; -+var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; -+function CloudDownloadIcon({ done = false }) { + ); + }; + var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; + function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) + (0, import_react5.createElement)("path", { d: CLOUD }), + done ? (0, import_react5.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react5.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react5.createElement)("path", { d: "M10 9.75v7" }), (0, import_react5.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -+ ); -+} -+var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); -+var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); -+var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); -+var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); -+var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); -+var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); -+var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); -+var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); -+var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); + ); + } + var 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 +5210,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"]); +var WorkflowIcon = lineIcon(["M4.5 4h4v3.5h-4z", "M11.5 12.5h4V16h-4z", "M6.5 7.5v3.5a2 2 0 0 0 2 2h3"]); -+var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); -+var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); -+var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); -+var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); -+var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); -+var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); -+function PinMarkIcon() { + 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 +5218,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() { +- return (0, import_react3.createElement)( + return (0, import_react5.createElement)( -+ "svg", -+ { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + "svg", + { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( + (0, import_react5.createElement)( -+ "g", -+ { transform: "rotate(45 10 10)" }, + "g", + { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) + (0, import_react5.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), + (0, import_react5.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -+ ) -+ ); -+} -+var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); -+var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); -+var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); -+var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); -+var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); -+var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); -+var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); -+var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); + ) + ); + } +@@ -3112,21 +5237,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"]); +var LinkIcon = lineIcon(["M8.5 11.5a3.5 3.5 0 0 0 5 0l2.5-2.5a3.5 3.5 0 0 0-5-5l-1 1", "M11.5 8.5a3.5 3.5 0 0 0-5 0L4 11a3.5 3.5 0 0 0 5 5l1-1"]); -+var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); -+var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); -+var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); + var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); + var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); + var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +var ChatIcon = lineIcon(["M4 5.5A1.5 1.5 0 0 1 5.5 4h9A1.5 1.5 0 0 1 16 5.5v6a1.5 1.5 0 0 1-1.5 1.5H9l-3.5 3v-3A1.5 1.5 0 0 1 4 11.5z"]); +var BotIcon = lineIcon(["M6 7.5h8a2 2 0 0 1 2 2V14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9.5a2 2 0 0 1 2-2z", "M10 4.5v3", "M10 3.5h.01", "M7.75 11.5h.01M12.25 11.5h.01"]); +var TerminalIcon = lineIcon(["M4.5 4.5h11a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14V6a1.5 1.5 0 0 1 1.5-1.5z", "M6.5 8.25L8.75 10 6.5 11.75", "M10.5 12h3"]); -+var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); -+var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; + var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); + var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +function FlyBotMark({ size = 24 }) { + return (0, import_react5.createElement)( + "svg", @@ -4152,951 +4017,670 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("rect", { className: "bt-flybot-body", x: 248, y: 104, width: 16, height: 34 }) + ); +} -+ -+// src/client/questions.js + + // src/client/questions.js +-var import_react4 = require("react"); +var import_react6 = require("react"); -+var keyLetter = (index) => String.fromCharCode(65 + index); -+var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); -+var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; -+function useOptionKeys(count, onPick, ownRef) { + var keyLetter = (index) => String.fromCharCode(65 + index); + var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); + var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; + function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); + const pick2 = (0, import_react6.useRef)(onPick); -+ pick2.current = onPick; + pick2.current = onPick; +- (0, import_react4.useEffect)(() => { + (0, import_react6.useEffect)(() => { -+ if (count === 0) return void 0; -+ const onKey = (event) => { -+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -+ if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -+ if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -+ const index = event.key.toUpperCase().charCodeAt(0) - 65; -+ if (index >= 0 && index < count) { -+ event.preventDefault(); -+ pick2.current(index); -+ } else if (index === count && ownRef.current) { -+ event.preventDefault(); -+ ownRef.current.focus(); -+ } -+ }; -+ document.addEventListener("keydown", onKey, true); -+ return () => document.removeEventListener("keydown", onKey, true); -+ }, [count, ownRef]); -+} -+function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { + 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 +5298,10 @@ + }, [count, ownRef]); + } + function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); + const [own, setOwn] = (0, import_react6.useState)(""); + const [picked, setPicked] = (0, import_react6.useState)([]); + const ownRef = (0, import_react6.useRef)(null); + const titleId = (0, import_react6.useId)(); -+ const options = optionsOf(question); -+ const multi = question.multiSelect === true; -+ const custom = own.trim(); -+ const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -+ const choose = (label) => { -+ if (multi) { -+ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -+ return; -+ } -+ onAnswer({ selected: [label] }); -+ }; -+ const submit = () => { -+ if (!canSubmit) return; -+ onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -+ }; -+ useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -+ const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; + const options = optionsOf(question); + const multi = question.multiSelect === true; + const custom = own.trim(); +@@ -3167,7 +5319,7 @@ + }; + useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); + const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { + const field = ownAnswer ? (0, import_react6.createElement)("textarea", { -+ ref: ownRef, -+ rows: 1, -+ value: own, -+ autoComplete: "off", -+ spellCheck: false, -+ className: "bt-q-input", -+ "aria-label": t("Your own answer"), -+ placeholder: t("Type your own answer"), -+ "aria-keyshortcuts": ownLetter?.toLowerCase(), -+ onChange: (event) => setOwn(event.target.value), -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ submit(); -+ } -+ }) : null; -+ const rows = options.map((option, optionIndex) => { -+ const selected = multi && picked.includes(option.label); + ref: ownRef, + rows: 1, + value: own, +@@ -3186,7 +5338,7 @@ + }) : null; + const rows = options.map((option, optionIndex) => { + const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "button", -+ { -+ key: option.label, -+ type: "button", -+ className: "bt-q-opt", -+ "aria-pressed": multi ? selected : void 0, -+ "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -+ onClick: () => choose(option.label) -+ }, + "button", + { + key: option.label, +@@ -3196,36 +5348,36 @@ + "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), + onClick: () => choose(option.label) + }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( + (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), + (0, import_react6.createElement)( -+ "span", -+ { className: "bt-q-body" }, + "span", + { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null + (0, import_react6.createElement)("span", { className: "bt-q-label" }, option.label), + option.description ? (0, import_react6.createElement)("span", { className: "bt-q-desc" }, option.description) : null -+ ), + ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null + selected && !raised ? (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) : null -+ ); -+ }); -+ if (raised && field) { + ); + }); + if (raised && field) { +- rows.push((0, import_react4.createElement)( + rows.push((0, import_react6.createElement)( -+ "label", -+ { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, + "label", + { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) + ownLetter ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, + (0, import_react6.createElement)("span", { className: "bt-q-body" }, field) -+ )); -+ } -+ let foot = null; -+ if (raised && multi) { + )); + } + let foot = null; + if (raised && multi) { +- foot = (0, import_react4.createElement)( + foot = (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-foot" }, + "div", + { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) + (0, import_react6.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), + (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -+ ); -+ } else if ((raised || multi) && canSubmit) { + ); + } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + foot = (0, import_react6.createElement)("div", { className: "bt-q-foot" }, (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -+ } + } +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "form", -+ { -+ className: raised ? "bt-q bt-q-raised" : "bt-q", -+ "aria-labelledby": titleId, -+ onSubmit: (event) => { -+ event.preventDefault(); -+ submit(); -+ } -+ }, -+ (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-head" }, -+ (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-text" }, + "form", + { + className: raised ? "bt-q bt-q-raised" : "bt-q", +@@ -3235,24 +5387,24 @@ + submit(); + } + }, +- (0, import_react4.createElement)( ++ (0, import_react6.createElement)( + "div", + { className: "bt-q-head" }, +- (0, import_react4.createElement)( ++ (0, import_react6.createElement)( + "div", + { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null + (0, import_react6.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), + question.detail ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, + raised && multi ? (0, import_react6.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -+ ), + ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) + (0, import_react6.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react6.createElement)(CloseIcon)) -+ ), + ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( + rows.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, + field && !raised ? (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-custom" }, + "div", + { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null + (0, import_react6.createElement)("label", { className: "bt-q-field" }, field), + !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -+ ) : null, -+ foot -+ ); - } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); -- }); -+function SettledQuestion({ question, answer }) { -+ const options = optionsOf(question); -+ const prompt = question?.question ?? ""; -+ const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -+ if (chosen.length === 0) { + ) : null, + foot + ); +@@ -3262,29 +5414,29 @@ + const prompt = question?.question ?? ""; + const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; + if (chosen.length === 0) { +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, + "div", + { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( + (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q-head" }, + "div", + { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) + (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt)), + (0, import_react6.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -+ ) -+ ); -+ } + ) + ); + } +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "div", -+ { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, + "div", + { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { + prompt ? (0, import_react6.createElement)("div", { className: "bt-q-head" }, (0, import_react6.createElement)("div", { className: "bt-q-text" }, (0, import_react6.createElement)("p", { className: "bt-q-title" }, prompt))) : null, + (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -+ const optionIndex = options.findIndex((option) => option.label === label); + const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "div", -+ { key: `${label}-${index}`, className: "bt-q-opt" }, + "div", + { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) + optionIndex >= 0 ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, + (0, import_react6.createElement)("span", { className: "bt-q-body" }, (0, import_react6.createElement)("span", { className: "bt-q-label" }, label)), + (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) -+ ); -+ })) -+ ); - } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); -+function QuestionCard({ matched }) { -+ const pending = matched; -+ const questions = pending.questions ?? []; + ); + })) + ); +@@ -3292,8 +5444,8 @@ + function QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); + const [index, setIndex] = (0, import_react6.useState)(0); + const [answers, setAnswers] = (0, import_react6.useState)([]); -+ const question = questions[index]; -+ if (question === void 0) return null; -+ const onAnswer = (answer) => { -+ const next = [...answers, { id: question.id, ...answer }]; -+ if (index + 1 < questions.length) { -+ setAnswers(next); -+ setIndex(index + 1); -+ return; + const question = questions[index]; + if (question === void 0) return null; + const onAnswer = (answer) => { +@@ -3305,10 +5457,10 @@ } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); -+ pending.answer({ answers: next }); + pending.answer({ answers: next }); }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); --} --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 -+function PendingQuestion({ sessionId, question, actions, inline }) { + function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); + const [answered, setAnswered] = (0, import_react6.useState)(null); -+ if (answered === question.id) return null; -+ const onAnswer = (answer) => { -+ setAnswered(question.id); -+ void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); + if (answered === question.id) return null; + const onAnswer = (answer) => { + setAnswered(question.id); +@@ -3318,10 +5470,10 @@ + setAnswered(question.id); + void actions.dismissQuestion(sessionId); }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); --} --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -+ const onDismiss = () => { -+ setAnswered(question.id); -+ void actions.dismissQuestion(sessionId); -+ }; +- return (0, import_react4.createElement)( + return (0, import_react6.createElement)( -+ "div", -+ { className: inline ? "bt-q-inline" : "bt-q-dock" }, + "div", + { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ); } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const question = useRoster((value) => value.questions?.[sessionId]); -+ const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -+ const loading = useSession?.(historyLoading) === true; -+ if (question === void 0 || question.callId && (drawn || loading)) return null; + function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +@@ -3329,16 +5481,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; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); + return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); -+function AnsweredCell({ node }) { -+ const data = node.data ?? {}; -+ const questions = Array.isArray(data.questions) ? data.questions : []; -+ if (questions.length === 0) { + function AnsweredCell({ node }) { + const data = node.data ?? {}; + const questions = Array.isArray(data.questions) ? data.questions : []; + if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; + return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -+ } -+ const answers = Array.isArray(data.answers) ? data.answers : []; + } + const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { + return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { -+ key: question.id, -+ question, -+ answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -+ }))); + key: question.id, + question, + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +@@ -3346,37 +5498,37 @@ } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; -- } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; -+ -+// src/client/secret-card.js + + // src/client/secret-card.js +-var import_react5 = require("react"); +var import_react7 = require("react"); -+var MIN_VALUE = 4; -+var NONE = {}; + var MIN_VALUE = 4; + var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +var KeyIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) + (0, import_react7.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), + (0, import_react7.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) -+); + ); +-var LockIcon = () => (0, import_react5.createElement)( +var LockIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) + (0, import_react7.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), + (0, import_react7.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) -+); + ); +-var WarnIcon = () => (0, import_react5.createElement)( +var WarnIcon = () => (0, import_react7.createElement)( -+ "svg", -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) + (0, import_react7.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), + (0, import_react7.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) -+); -+var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); -+var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); -+var scopeLabel = (scope, roster) => { + ); + var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); + var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); + var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); + if (scope === "all") return t("All my Bots"); -+ const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -+ return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -+}; -+var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -+function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { + const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); + return names.length === 0 ? t("No Bot") : names.join(t(" and ")); + }; + var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; + function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { + return (0, import_react7.createElement)("input", { -+ className: "bt-secret-input", -+ type: MASK_BY_CSS ? "text" : "password", -+ "data-masked": MASK_BY_CSS ? "" : void 0, -+ value, -+ autoFocus, -+ autoComplete: MASK_BY_CSS ? "off" : "new-password", -+ autoCorrect: "off", -+ autoCapitalize: "off", -+ spellCheck: false, -+ "data-1p-ignore": "", -+ "data-lpignore": "true", -+ "data-bwignore": "", -+ "data-form-type": "other", -+ "aria-label": label, -+ placeholder: t("Paste or type the key"), -+ onChange: (event) => onChange(event.target.value), -+ onKeyDown: (event) => { -+ if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -+ event.preventDefault(); -+ onEnter?.(); -+ } -+ }); + className: "bt-secret-input", + type: MASK_BY_CSS ? "text" : "password", + "data-masked": MASK_BY_CSS ? "" : void 0, +@@ -3401,42 +5553,42 @@ + }); } --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 ScopeSwitch({ value, onChange, bot }) { + function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, + "div", + { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { + (0, import_react7.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), + options.map(([id, text2]) => (0, import_react7.createElement)("button", { -+ key: id, -+ type: "button", -+ role: "radio", -+ "aria-checked": value === id, -+ className: "bt-secret-seg", -+ onClick: () => onChange(id) + key: id, + type: "button", + role: "radio", + "aria-checked": value === id, + className: "bt-secret-seg", + onClick: () => onChange(id) +- }, text)) + }, text2)) ); } --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]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); -- } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -+function SecretHead({ bot, title, detail, name, onClose }) { + function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-head bt-secret-head" }, + "div", + { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( + (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 28 }), (0, import_react7.createElement)("span", { className: "bt-secret-lock" }, (0, import_react7.createElement)(LockIcon))), + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-text" }, + "div", + { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") + (0, import_react7.createElement)("p", { className: "bt-q-title" }, title), + (0, import_react7.createElement)("div", { className: "bt-q-detail" }, (0, import_react7.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -+ ), + ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null + onClose ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react7.createElement)(CloseIcon)) : null ); } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+function SecretCard({ bot, request, roster, actions }) { + function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); + const [value, setValue] = (0, import_react7.useState)(""); + const [scope, setScope] = (0, import_react7.useState)("all"); + const [replacing, setReplacing] = (0, import_react7.useState)(false); + const [busy, setBusy] = (0, import_react7.useState)(false); + const [error, setError] = (0, import_react7.useState)(""); + const noteId = (0, import_react7.useId)(); -+ const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -+ const run = (promise) => { -+ setBusy(true); -+ setError(""); -+ return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -+ }; -+ const cancel = () => run(actions.secretCancel(request.id)); -+ const long = value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -+ }; -+ const allow = () => { -+ if (!busy) void run(actions.secretAllow(request.id)); -+ }; -+ const filling = request.kind === "fill" || replacing; + const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); + const run = (promise) => { + setBusy(true); +@@ -3452,73 +5604,73 @@ + if (!busy) void run(actions.secretAllow(request.id)); + }; + const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( + const warn = insecure() ? (0, import_react7.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react7.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, + "div", + { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { + (0, import_react7.createElement)(SecretHead, { -+ bot, -+ title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -+ detail: request.purpose, -+ name: request.name, -+ onClose: busy ? void 0 : cancel -+ }), + bot, + title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), + detail: request.purpose, + name: request.name, + onClose: busy ? void 0 : cancel + }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( + request.kind === "allow" ? (0, import_react7.createElement)("div", { className: "bt-secret-saved" }, (0, import_react7.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, + filling ? (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-body" }, + "div", + { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null + (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), + request.kind === "fill" ? (0, import_react7.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -+ ) : null, + ) : null, +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-note", id: noteId }, -+ t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), + "div", + { className: "bt-secret-note", id: noteId }, + t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), + (0, import_react7.createElement)("code", null, `$DSH_SECRET_${request.name}`), -+ ".", -+ filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -+ ), -+ warn, + ".", + filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" + ), + warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( + error ? (0, import_react7.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-foot" }, + "div", + { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + request.kind === "allow" && !replacing ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, + (0, import_react7.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), + filling ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -+ ) -+ ); + ) + ); } --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -+function SettledSecret({ bot, request, roster }) { -+ const canceled = request.status === "canceled"; + function SettledSecret({ bot, request, roster }) { + const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( + const text2 = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); + return (0, import_react7.createElement)( -+ "div", + "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( + { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text2, "data-status": request.status }, + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-q-head" }, + "div", + { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( + (0, import_react7.createElement)("span", { className: "bt-secret-who" }, (0, import_react7.createElement)(BotMark, { bot, size: 22 })), + (0, import_react7.createElement)("div", { className: "bt-q-text" }, (0, import_react7.createElement)( -+ "p", -+ { className: "bt-q-title" }, + "p", + { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) + text2, + canceled ? null : (0, import_react7.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -+ )), + )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) + canceled ? (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react7.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react7.createElement)(CheckIcon)) -+ ) + ) ); } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); -+function RequestView({ bot, request, roster, actions }) { + function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); + return request.status === "pending" ? (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react7.createElement)(SettledSecret, { bot, request, roster }); } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); -+function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -+ const bot = roster.byId[sessionId]; -+ if (!bot) return null; -+ const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); + function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { + const bot = roster.byId[sessionId]; + if (!bot) return null; + const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); + if (request) return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, (0, import_react7.createElement)(RequestView, { bot, request, roster, actions })); -+ if (done && /already saved/.test(contentText(content))) { + if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); + return (0, import_react7.createElement)("div", { className: "bt-event" }, (0, import_react7.createElement)("span", { className: "bt-secret-event" }, (0, import_react7.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -+ } -+ return null; + } + return null; } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); -+function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const shown = useCards?.((state) => state[sessionId]) ?? NONE; -+ const loading = useSession?.(historyLoading) === true; -+ const bot = roster.byId[sessionId]; -+ if (!bot || currentPart(bot) !== sessionId) return null; -+ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -+ const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -+ if (waiting.length === 0) return null; +@@ -3531,44 +5683,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; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); + return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; -+function ScopeEditor({ secret, roster, onSave }) { + function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); + const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); + const [picked, setPicked] = (0, import_react7.useState)(secret.scope === "all" ? [] : secret.scope); -+ const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); + const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( + const next = mode === "all" ? "all" : mode === "none" ? [] : picked; + const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit-block" }, + "div", + { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( + (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, + "div", + { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( + [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text2]) => (0, import_react7.createElement)( -+ "label", -+ { key: id, className: "bt-secret-radio" }, + "label", + { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text + (0, import_react7.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), + text2 -+ )) -+ ), + )) + ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { + mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { -+ key: bot.id, -+ type: "button", -+ className: "bt-secret-bot", -+ "aria-pressed": picked.includes(bot.id), -+ onClick: () => toggle(bot.id) + key: bot.id, + type: "button", + className: "bt-secret-bot", + "aria-pressed": picked.includes(bot.id), + onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( + }, (0, import_react7.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react7.createElement)(CheckIcon) : null))) : null, + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, + "div", + { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) -+ ) + ) ); } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -+function ValueEditor({ secret, onSave }) { + function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); + const [value, setValue] = (0, import_react7.useState)(""); + const [replaced, setReplaced] = (0, import_react7.useState)(false); -+ const long = value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!long) return; -+ void Promise.resolve(onSave(value)).then((ok) => { -+ if (ok) { -+ setValue(""); -+ setReplaced(true); -+ } -+ }); -+ }; -+ const edit = (next) => { -+ setValue(next); -+ setReplaced(false); -+ }; + const long = value.trim().length >= MIN_VALUE; + const save = () => { + if (!long) return; +@@ -3583,66 +5737,66 @@ + setValue(next); + setReplaced(false); + }; +- return (0, import_react5.createElement)( + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit-block" }, + "div", + { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( + (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-inline" }, + "div", + { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) + (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -+ ), + ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -+} -+function SecretRow({ secret, roster, actions, run, open, onToggle }) { + } + function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { + const [confirm, setConfirm] = (0, import_react7.useState)(false); + (0, import_react7.useEffect)(() => { -+ if (!open) setConfirm(false); -+ }, [open]); -+ const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -+ const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -+ const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); + if (!open) setConfirm(false); + }, [open]); + const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); + const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); + const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-item", "data-open": open ? "" : void 0 }, + "div", + { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, + "button", + { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ "span", -+ { className: "bt-set-copy" }, + "span", + { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) + (0, import_react7.createElement)("span", { className: "bt-secret-title" }, (0, import_react7.createElement)(KeyIcon), (0, import_react7.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react7.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), + (0, import_react7.createElement)("span", { className: "bt-set-hint" }, meta) -+ ), + ), +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ "span", -+ { className: "bt-set-control" }, + "span", + { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) + secret.scope === "all" ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("All my Bots")) : allowed.length === 0 ? (0, import_react7.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react7.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react7.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -+ ) -+ ), + ) + ), +- open ? (0, import_react5.createElement)( + open ? (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit" }, + "div", + { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( + (0, import_react7.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), + (0, import_react7.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, -+ confirm ? [ + "div", + { className: "bt-secret-row-actions" }, + confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) + (0, import_react7.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), + (0, import_react7.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), + (0, import_react7.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) + ] : (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react7.createElement)(TrashIcon), t("Delete")) -+ ) -+ ) : null + ) + ) : null ); } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -+function AddSecret({ actions, run, onDone }) { + function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); + const [name, setName] = (0, import_react7.useState)(""); + const [purpose, setPurpose] = (0, import_react7.useState)(""); + const [value, setValue] = (0, import_react7.useState)(""); -+ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!ready) return; -+ void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -+ if (ok) { -+ setValue(""); -+ onDone(); -+ } -+ }); -+ }; + const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; + const save = () => { + if (!ready) return; +@@ -3653,43 +5807,43 @@ + } + }); + }; +- return (0, import_react5.createElement)( + return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-set-row bt-set-paste bt-secret-add" }, + "div", + { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( + (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), + (0, import_react7.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), + (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), + (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, + "div", + { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all your Bots; change who may use it afterwards.")), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) ) ); } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -+function SecretsPage({ roster, actions, run, Section }) { + function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); + const [open, setOpen] = (0, import_react7.useState)(null); + const [adding, setAdding] = (0, import_react7.useState)(false); -+ const secrets = roster.secrets ?? []; -+ return [ + const secrets = roster.secrets ?? []; + return [ +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ "p", -+ { key: "about", className: "bt-secret-about" }, -+ t("Keys your Bots asked for. Bots never see the values: they use them as "), + "p", + { key: "about", className: "bt-secret-about" }, + t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), + (0, import_react7.createElement)("code", null, "$DSH_SECRET_NAME"), -+ t(" in their shell, and every value is replaced by "), + t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), + (0, import_react7.createElement)("code", null, "[secret:NAME]"), -+ t(" before anything goes to a model.") -+ ), + t(" before anything goes to a model.") + ), +- (0, import_react5.createElement)( + (0, import_react7.createElement)( -+ Section, -+ { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, + Section, + { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null + secrets.length === 0 && !adding ? (0, import_react7.createElement)("div", { className: "bt-set-row" }, (0, import_react7.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, + secrets.map((secret) => (0, import_react7.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), + adding ? (0, import_react7.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -+ ), + ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) + adding ? null : (0, import_react7.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -+ ]; -+} -+var SECRET_CSS = ` -+.bt-secret-head{align-items:center;gap:10px} -+.bt-secret-who{position:relative;flex:none;display:inline-flex} -+.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} -+.bt-secret-lock svg{width:11px;height:11px} -+.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} -+.bt-secret-body{display:flex;flex-direction:column;gap:8px} -+.bt-secret-field{align-items:center;min-height:36px} -+.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} -+.bt-secret-input[data-masked]{-webkit-text-security:disc} -+.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} -+.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} -+.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} -+.bt-secret-glide[data-at="1"]{transform:translateX(100%)} -+.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} -+.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} -+.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -+/* A key card asks the user to act, so the process group carrying it stays open in every -+ work-details mode (same override as .bt-group-replies in styles.js). */ -+[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} -+[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} -+.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} -+.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} -+@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} -+.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} -+.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} -+.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} -+.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} -+.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} -+.bt-secret-done .bt-q-head{align-items:center} -+.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} -+.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} -+.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -+.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} -+@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} -+@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} -+.bt-secret-event{display:inline-flex;align-items:center;gap:5px} -+.bt-secret-dock{flex-direction:column;align-items:center} -+.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} -+.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} -+.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} -+.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} -+.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} -+.bt-secret-faces{display:inline-flex} -+.bt-secret-faces>*+*{margin-left:-6px} -+.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} -+.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} -+.bt-secret-radios{display:flex;gap:16px;font-size:13px} -+.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} -+.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} -+.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} -+.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} -+.bt-secret-bot svg{width:13px;height:13px} -+.bt-secret-inline{display:flex;gap:8px;align-items:center} -+.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} -+.bt-secret-row-actions .bt-set-hint{flex:1} -+.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} -+.bt-secret-danger svg{width:13px;height:13px} -+.bt-secret-add .bt-input{height:34px} -+@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} -+`; + ]; + } + var SECRET_CSS = ` +@@ -3756,6 +5910,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}} + `; --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); @@ -5109,48 +4693,8 @@ diff --git a/client.js b/client.js + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); --} --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); - }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++ }; ++ }; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; @@ -5187,42 +4731,9 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); ++ } + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); - } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( ++ } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; +} +function facePersonLabel(person) { @@ -5279,166 +4790,91 @@ diff --git a/client.js b/client.js + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react8.createElement)( - "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) ++ "div", + { className: "bt-held-note", "data-state": held.status, title: failed && held.error ? held.error : void 0 }, + (0, import_react8.createElement)("span", null, held.status === "approved" ? t("Sent to {to}", { to: held.toName }) : held.status === "auto" ? t("Auto-sent to {to}", { to: held.toName }) : failed ? t("Not sent: delivery failed") : t("Not sent")), + // #968: the terminal answer comes from one predicate, not from `status === 'failed'` here and a + // different field elsewhere. A failure that can never succeed draws no button on any surface. + failed && canRetry(held) ? (0, import_react8.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react8.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); - } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( ++ ); ++ } + const plain = (0, import_react8.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, + (0, import_react8.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), + (0, import_react8.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + (0, import_react8.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) ++ "div", + { className: "bt-flybot-approval-actions" }, + (0, import_react8.createElement)("button", { type: "button", disabled: busy, onClick: () => decide("reject") }, t("Reject")), + (0, import_react8.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) - ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot ++ ), + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null + ); + return (0, import_react8.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain - ); - } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) -- ); -- } -- return (0, import_react4.createElement)( -+ -+// src/client/turn-reasoning.js -+function turnReasoning(steps, current) { -+ const members = steps.length ? steps : [current]; -+ if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -+ return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -+} -+var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); -+var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); -+var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); ++ ); ++} + -+// src/client/cells.js + // src/client/turn-reasoning.js + function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; +@@ -3767,7 +6059,7 @@ + var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); + + // src/client/cells.js +-var import_react6 = require("react"); +var import_react9 = require("react"); -+var import_react_dom = require("react-dom"); -+var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); -+var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); -+function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -+ const onClick = (event) => { -+ const target = mentionTarget(event); -+ if (target === null) return; -+ event.preventDefault(); -+ event.stopPropagation(); -+ actions.openSession(target); -+ }; -+ const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; + 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 +6072,29 @@ + actions.openSession(target); + }; + const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -+ { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, + { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { + texts2.map((text2, index) => { -+ const key = msgKey ? `${msgKey}:${index}` : void 0; -+ const picked = key ? reactions?.[index] ?? [] : []; -+ const last = index === texts2.length - 1; -+ const live = streaming && last; + const key = msgKey ? `${msgKey}:${index}` : void 0; + const picked = key ? reactions?.[index] ?? [] : []; + const last = index === texts2.length - 1; + const live = streaming && last; +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -+ { key: index, className: "bt-msg" }, + { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( + (0, import_react9.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ lead && last ? lead : null, + "div", + { className: "bt-msg-line" }, + lead && last ? lead : null, +- (0, import_react6.createElement)( + (0, import_react9.createElement)( -+ "div", -+ { className: "bt-bubble" }, + "div", + { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) + (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text2, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -+ ), + ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) + live ? null : (0, import_react9.createElement)(MessageTools, { text: text2, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -+ ), + ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { + picked.length ? (0, import_react9.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react9.createElement)("button", { -+ key: emoji, -+ type: "button", -+ className: "bt-react", -+ "aria-label": t("Remove reaction {emoji}", { emoji }), -+ onClick: () => actions.toggleReaction(msgKey, index, emoji) -+ }, emoji))) : null - ); -- })) -+ }) + key: emoji, + type: "button", + className: "bt-react", +@@ -3813,9 +6105,22 @@ + }) ); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; -+var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; + var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text2) { + if (room2?.origin !== "face-to-face" || room2.face?.status !== "closed") return false; + const match = String(text2 ?? "").trim().match(/^(.+) left(?: the room)?$/); @@ -5451,110 +4887,85 @@ diff --git a/client.js b/client.js + } + return false; +} -+function useDismiss(open, close, refs) { + function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (refs.some((ref) => ref.current?.contains(event.target))) return; -+ close(); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ close(); -+ } -+ }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ }; -+ }, [open]); -+} + if (!open) return void 0; + const onDown = (event) => { + if (refs.some((ref) => ref.current?.contains(event.target))) return; +@@ -3835,12 +6140,12 @@ + }; + }, [open]); + } +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +function MessageTools({ text: text2, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react9.useState)(null); + const [place, setPlace] = (0, import_react9.useState)(null); + const [copied, setCopied] = (0, import_react9.useState)(false); + const tools = (0, import_react9.useRef)(null); + const pop = (0, import_react9.useRef)(null); -+ const close = () => setPanel(null); -+ useDismiss(panel !== null, close, [tools, pop]); -+ const open = (kind, event) => { -+ if (panel === kind) { -+ close(); - return; - } -- pending.answer({ answers: next }); -+ const rect = event.currentTarget.getBoundingClientRect(); -+ const below = rect.bottom + 220 < window.innerHeight; -+ setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -+ setPanel(kind); + const close = () => setPanel(null); + useDismiss(panel !== null, close, [tools, pop]); + const open = (kind, event) => { +@@ -3854,36 +6159,36 @@ + setPanel(kind); }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); --} --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -+ const copy2 = () => { + const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { + void navigator.clipboard?.writeText(text2).then(() => { -+ setCopied(true); -+ setTimeout(() => setCopied(false), 1e3); -+ }); + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -+ const download2 = () => { + const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); + const url = URL.createObjectURL(new Blob([text2], { type: "text/markdown" })); -+ const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -+ link.click(); -+ setTimeout(() => URL.revokeObjectURL(url), 1e3); + const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1e3); }; -- return (0, import_react4.createElement)( +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { + const item = (label, Icon, run) => (0, import_react9.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -+ close(); -+ run(); + close(); + run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( + } }, (0, import_react9.createElement)(Icon), label); + return (0, import_react9.createElement)( "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -+ { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, + { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( + react ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react9.createElement)(SmileIcon)) : null, + sessionId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text2) }, (0, import_react9.createElement)(ReplyIcon)) : null, + (0, import_react9.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react9.createElement)(CheckIcon) : (0, import_react9.createElement)(DotsIcon)), + panel === "react" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( -+ "div", -+ { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, + "div", + { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { + REACTIONS.map((emoji) => (0, import_react9.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -+ close(); -+ react(emoji); -+ } }, emoji)) -+ ), document.body) : null, + close(); + react(emoji); + } }, emoji)) + ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( + panel === "more" ? (0, import_react_dom.createPortal)((0, import_react9.createElement)( -+ "div", -+ { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -+ item(t("Copy"), CopyIcon, copy2), -+ item(t("Download"), DownloadIcon, download2) -+ ), document.body) : null + "div", + { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, + item(t("Copy"), CopyIcon, copy2), +@@ -3891,12 +6196,40 @@ + ), document.body) : null ); } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); --} --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; @@ -5566,69 +4977,9 @@ diff --git a/client.js b/client.js + for (const part of graphemes(line2)) { + if (kept.length + part.length > PEEK_MAX - CUT.length) break; + kept += part; - } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); --} -- --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++ } + return kept + CUT; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); -- } ++}; +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); +var peekLine = (text2) => { + const lines = text2.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); @@ -5639,8 +4990,10 @@ diff --git a/client.js b/client.js + if (typeof text2 !== "string" || text2.trim() === "") return null; + const peek = peekLine(text2); + return (0, import_react9.createElement)( -+ "details", -+ { className: "bt-think" }, + "details", + { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) + (0, import_react9.createElement)( + "summary", + null, @@ -5649,18 +5002,19 @@ diff --git a/client.js b/client.js + peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null + ), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text2) -+ ); -+} -+function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -+ const tail = useTurnData?.("turn-tail"); -+ const location2 = node.location; -+ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -+ const seq = node.data.finalNode?.seq; + ); + } + function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +@@ -3904,19 +6237,33 @@ + const location2 = node.location; + const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; + const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { + return (0, import_react9.useMemo)(() => { -+ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -+ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -+ }, [fileMentions, openFile, seq, tail, turn]); -+} + if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); + } +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; @@ -5672,41 +5026,45 @@ diff --git a/client.js b/client.js + if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; + return (0, import_react9.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react9.createElement)(BotMark, { bot, size: 22 })); +} -+function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+ const roster = useRoster((value) => value); -+ const data = node.data; -+ const steps = useTurnSteps(String(data.turn)); -+ const contexts = useTurnContext(String(data.turn)); -+ const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; + const steps = useTurnSteps(String(data.turn)); + const contexts = useTurnContext(String(data.turn)); + const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); + const closing = useClosingText(node, useTurnData); -+ const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); + const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); + const person = typedBy(roster, sessionId, data.turn); + const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text2) => text2.trim() !== ""); -+ const running = data.status === "running"; -+ useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -+ if (hidden) return null; -+ const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -+ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -+ if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -+ const selfId = roster.byId[sessionId]?.id ?? sessionId; + 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 +6271,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; +- return (0, import_react6.createElement)( +- import_react6.Fragment, + const bubbles = texts2.length ? person ? (0, import_react9.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; + return (0, import_react9.createElement)( + import_react9.Fragment, -+ null, -+ relays.map((relay) => { -+ const source = relay.source; -+ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + null, + relays.map((relay) => { + const source = relay.source; + const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { + return (0, import_react9.createElement)(EventLine, { -+ key: relay.seq, -+ prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -+ bots: [sender], -+ onOpen: () => { -+ if (sender.id) actions.openExchange(selfId, sender.id); -+ }, -+ whole: true -+ }); -+ }), + key: relay.seq, + prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), + bots: [sender], +@@ -3940,61 +6288,230 @@ + whole: true + }); + }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles + ); @@ -5729,35 +5087,47 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); -+} -+function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { + ); + } + function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)( + (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, + "button", + { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) + (0, import_react9.createElement)("span", null, prefix), + (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); -+ } -+ const parts = []; + ) + ); + } + const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); + parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { + bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( + if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react9.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, + "button", + { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); + bot.name + )); + }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text2, bot) { @@ -5769,11 +5139,8 @@ diff --git a/client.js b/client.js + if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); - }); - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( ++ }); ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -5797,10 +5164,12 @@ diff --git a/client.js b/client.js + } + if (held === "held" || isHeldLine(room2, text2, bot)) return null; + const lead = (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + (0, import_react9.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +- return (0, import_react6.createElement)( + const name = (0, import_react9.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -5809,27 +5178,12 @@ diff --git a/client.js b/client.js + ); + return (0, import_react9.createElement)( "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -+ { className: "bt-group-msg" }, + { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + name, + (0, import_react9.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( ++ ); ++} +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -5870,10 +5224,7 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react9.createElement)( - "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") ++ "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react9.createElement)( @@ -5893,26 +5244,18 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react9.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react9.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + name, + (0, import_react9.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react9.createElement)(GlobeMark), handle) ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text2)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); ++ ); ++} +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -5932,13 +5275,7 @@ diff --git a/client.js b/client.js + const [error, setError] = (0, import_react9.useState)(""); + const download2 = async (file) => { + setBusy(file.id); - setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); ++ setError(""); + try { + const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); + const value = got?.value ?? got; @@ -5952,9 +5289,7 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); + } - }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); ++ }; + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -5964,283 +5299,231 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); -+} -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { + ); + } + function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null -+ ); -+} -+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -+ const self = roster.byId[sessionId]; -+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -+ const other = team || !scope ? void 0 : findBot(scope); -+ const target = other && other.id !== self?.id ? other : self; -+ if (!target) return null; -+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); + ); + } + function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +@@ -4005,14 +6522,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 }); +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, + "div", + { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) + (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) -+ ); -+} -+function PartLine({ time }) { + ); + } + function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); + return (0, import_react9.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react9.createElement)("span", null, dayLabel(time)) : null); -+} -+var scrollParent = (node) => { -+ for (let at = node.parentElement; at; at = at.parentElement) { -+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -+ } -+ return null; -+}; -+function EarlierParts({ sessionId, roster, actions }) { + } + var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { +@@ -4021,11 +6538,11 @@ + return null; + }; + function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); + const [view, setView] = (0, import_react9.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react9.useRef)(null); + const busy = (0, import_react9.useRef)(false); + const alive = (0, import_react9.useRef)(true); + const restore = (0, import_react9.useRef)(null); -+ const load = (cursor) => { -+ if (busy.current) return; -+ busy.current = true; -+ setView((current) => ({ ...current, loading: true, failed: false })); -+ actions.earlier(sessionId, cursor).then((page) => { -+ busy.current = false; -+ if (!alive.current) return; -+ const scroller = top.current && scrollParent(top.current); -+ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -+ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -+ }, () => { -+ busy.current = false; -+ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -+ }); + const load = (cursor) => { + if (busy.current) return; + busy.current = true; +@@ -4041,19 +6558,19 @@ + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ alive.current = true; -+ load(void 0); -+ return () => { -+ alive.current = false; -+ }; -+ }, [sessionId]); + alive.current = true; + load(void 0); + return () => { + alive.current = false; + }; + }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react9.useLayoutEffect)(() => { -+ const pending = restore.current; -+ restore.current = null; -+ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -+ }, [view.items]); + const pending = restore.current; + restore.current = null; + if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; + }, [view.items]); +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ const node = top.current; -+ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -+ const observer = new IntersectionObserver((entries2) => { -+ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -+ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -+ observer.observe(node); -+ return () => observer.disconnect(); -+ }, [view.cursor, view.loading, view.failed]); -+ const selfId = roster.byId[sessionId]?.id; -+ const rows = []; -+ for (const item of view.items) { -+ const last = rows.at(-1); -+ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -+ else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -+ } + 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 +6586,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); + } +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -+ { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( + (0, import_react9.createElement)( "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -+ { ref: top, className: "bt-earlier-top" }, + { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) -+ rows.map((row, index) => { -+ const key = rows.length - index; -+ switch (row.kind) { -+ case "divider": + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { + case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); + return (0, import_react9.createElement)(PartLine, { key, time: row.time }); -+ case "user": + case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( + return (0, import_react9.createElement)("div", { key, className: "bt-urow" }, (0, import_react9.createElement)( -+ "div", -+ { className: "bt-ububble" }, + "div", + { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null + row.text ? (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, + row.images ? (0, import_react9.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -+ )); -+ case "answer": + )); + case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); + return (0, import_react9.createElement)(AnsweredCell, { key, node: { data: row } }); -+ case "bot": -+ return (0, import_react9.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -+ case "event": { -+ if (row.role === "report") return (0, import_react9.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -+ const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -+ const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -+ return (0, import_react9.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -+ if (selfId && other.id) actions.openExchange(selfId, other.id); -+ }, whole: true }); -+ } -+ default: -+ return null; -+ } -+ }), -+ (0, import_react9.createElement)(PartLine, { time: view.startedAt }) - ); - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); -+function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const data = node.data; -+ const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -+ const selfId = roster.byId[sessionId]?.id ?? sessionId; -+ const deferred = source.kind === "bot" && source.deferred === true; -+ const relayed = isRelayContext(data); -+ const turn = node.location?.turn?.turn ?? data.turn; + case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); ++ return (0, import_react9.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); + case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); ++ if (row.role === "report") return (0, import_react9.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); + const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; + const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { ++ return (0, import_react9.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { + if (selfId && other.id) actions.openExchange(selfId, other.id); + }, whole: true }); + } +@@ -4105,7 +6622,7 @@ + return null; + } + }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ (0, import_react9.createElement)(PartLine, { time: view.startedAt }) + ); + } + function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +@@ -4116,7 +6633,7 @@ + const deferred = source.kind === "bot" && source.deferred === true; + const relayed = isRelayContext(data); + const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react9.useLayoutEffect)(() => { -+ if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -+ }, [relayed, sessionId, turn, source.senderSessionId]); -+ const order = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -+ }); -+ const peers = useTurnClock((state) => { -+ if (!relayed) return ""; -+ const turns = state[sessionId] ?? {}; -+ return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const hiddenTurns = roster.exchangeTurns[sessionId]; -+ const unreadOpening = useTurnClock((state) => { -+ const first = openingTurn(state[sessionId], order, hiddenTurns); -+ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -+ }); -+ const body = triggerBody(); + if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); + }, [relayed, sessionId, turn, source.senderSessionId]); + const order = useSessions((list) => { +@@ -4135,23 +6652,31 @@ + return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + }); + const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; + return unreadOpening ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)("div", { className: "bt-open-new" }, (0, import_react9.createElement)(NewDivider), (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -+ function triggerBody() { + function triggerBody() { + if (source.form === "remote-chat") return null; + if (source.shown === true) return null; + if (source.remote?.handle) { + const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; + } -+ if (source.kind === "bot") { + if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; + if (source.role === "mention") return (0, import_react9.createElement)("div", { className: "bt-event" }, (0, import_react9.createElement)("span", null, contentText(data.content))); -+ if (source.role === "secret" || deferred) return null; -+ if (source.role === "post") { -+ if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.role === "secret" || deferred) return null; + if (source.role === "post") { + if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.notice === true || source.senderSessionId === "face-notice") return null; -+ const room2 = roster.roomsById[sessionId]; -+ const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); + return (0, import_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -+ } -+ if (source.role === "report") { + } + if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -+ } + } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); + if (source.role === "resume" || source.resumed === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); + if (source.role === "retry" || source.retried === true) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -+ const run = relayed ? commRunOf( -+ order.split(",").filter(Boolean).map(Number), -+ Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -+ turn, -+ hiddenTurns -+ ) : null; -+ if (run === "folded") return null; -+ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -+ const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -+ const bots = [sender]; -+ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -+ if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -+ } + const 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 +6690,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); + } +- return (0, import_react6.createElement)(EventLine, { + return (0, import_react9.createElement)(EventLine, { -+ prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -+ bots, -+ onOpen: (bot) => actions.openExchange(selfId, bot.id), -+ whole: true -+ }); -+ } -+ if (source.kind === "schedule") { -+ const prompts = reminderPrompts(contentText(data.content)); + 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 +6699,24 @@ + } + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -+ } -+ if (source.kind === "user-question-reply") return null; + } + if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -+ } -+} -+function NewDivider() { + } + } + function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); + return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); -+} -+function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -+ const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + } + function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (!open) turnProcess.setOpen(true); -+ }, [open, turnProcess]); -+ const turn = node?.location?.turn?.turn ?? node?.data?.turn; -+ const start = node?.location?.turn?.start?.time; -+ const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); + if (!open) turnProcess.setOpen(true); + }, [open, turnProcess]); + const turn = node?.location?.turn?.turn ?? node?.data?.turn; + const start = node?.location?.turn?.start?.time; + const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "first", start); -+ }, [sessionId, turn, start]); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ const own = state[sessionId]?.[turn]; -+ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -+ }); + actions.noteTurnTime(sessionId, turn, "first", start); + }, [sessionId, turn, start]); + const visit = useUi((state) => state.visits?.[sessionId]); +@@ -4199,11 +6724,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; + }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; + return fresh ? (0, import_react9.createElement)(NewDivider) : null; -+} -+function Nothing() { -+ return null; -+} + } + function Nothing() { + return null; + } +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -6296,87 +5579,14 @@ diff --git a/client.js b/client.js + } + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); ++} + var SEPARATOR_GAP_MS = 30 * 60 * 1e3; + function clockLabel(time, now = Date.now()) { + const date = new Date(time); +@@ -4233,10 +6814,19 @@ + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); -+var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -+function clockLabel(time, now = Date.now()) { -+ const date = new Date(time); -+ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -+ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -+ if (days === 0) return t("Today {time}", { time: clock4 }); -+ if (days === 1) return t("Yesterday {time}", { time: clock4 }); -+ const sameYear = date.getFullYear() === new Date(now).getFullYear(); -+ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -+function nextTurnStart(turns, turn) { -+ let next; -+ for (const key in turns) { -+ const candidate = Number(key); -+ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return null; -+ return next === void 0 ? void 0 : turns[next].first; - } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; - } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -+var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; + var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { @@ -6386,180 +5596,105 @@ diff --git a/client.js b/client.js + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; -+function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -+ const turn = location2.turn; -+ const time = location2.end.time; + function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; + const time = location2.end.time; +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ actions.noteTurnTime(sessionId, turn, "end", time); -+ }, [sessionId, turn, time]); -+ const label = useTurnClock((state) => { -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -+ }); -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const fresh = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number" || time > visit.seen) return false; -+ const start = nextTurnStart(state[sessionId] ?? {}, turn); -+ return start !== void 0 && start > visit.seen && start <= visit.entered; -+ }); -+ if (!label && !fresh) return null; + actions.noteTurnTime(sessionId, turn, "end", time); + }, [sessionId, turn, time]); + const label = useTurnClock((state) => { +@@ -4250,15 +6840,15 @@ + return start !== void 0 && start > visit.seen && start <= visit.entered; + }); + if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, + return (0, import_react9.createElement)( + import_react9.Fragment, -+ null, + null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null + label ? (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -+function Shimmer({ children }) { + function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); -+} -+var PHRASES = { -+ thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -+ working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] -+}; -+var TOOL_PHRASES = [ -+ [/web_?search|search_?web/i, "Searching online"], -+ [/fetch|browse|browser|web/i, "Reading a page"], -+ [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -+ [/^(read|view|cat)\b|read_/i, "Opening a file"], -+ [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -+ [/write|edit|patch|apply|replace/i, "Writing changes"], -+ [/image|photo|draw/i, "Working on an image"] -+]; -+var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; -+function toolActivity(name, arg, findBot, self) { -+ switch (name) { -+ case "ask_user": -+ return null; -+ case "request_secret": -+ return null; -+ case "list_secrets": -+ return { label: t("Checking the keys"), state: "searching" }; -+ case "ask_user_question": -+ return { label: t("Waiting for your answer"), state: "alert" }; -+ case "group_relay": -+ return { relay: true, state: "orbit" }; -+ case "message_bot": { -+ const target = findBot(arg("to")); -+ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")); -+ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -+ } -+ case "create_bot": -+ return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -+ return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -+ } -+ case "list_bots": -+ return { label: t("Checking the team"), state: "searching" }; -+ case "read_group_chat": -+ return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -+ case "read_own_chat": -+ return { label: t("Looking back through the chat"), state: "searching" }; -+ case "remember": -+ return { label: t("Saving to memory"), state: "working" }; -+ case "forget": -+ return { label: t("Updating memory"), state: "working" }; -+ case "recall": -+ return { label: t("Checking memory"), state: "searching" }; -+ default: -+ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -+ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -+ return { label: phrase ? t(phrase) : null, state: toolState(name) }; -+ } -+} -+function workPhrase(tool) { -+ if (!tool) return t("Thinking about it"); + } + var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +@@ -4320,19 +6910,22 @@ + } + function workPhrase(tool) { + if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); +} +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; -+} -+function useLiveNote(actions, sessionId, id, entry) { -+ const key = entry ? JSON.stringify(entry) : ""; + } + function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { + (0, import_react9.useEffect)(() => { -+ if (!key) return void 0; -+ actions.noteLive(sessionId, id, JSON.parse(key)); -+ return () => actions.noteLive(sessionId, id, null); -+ }, [sessionId, id, key]); -+} + if (!key) return void 0; + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); + } +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react9.useState)({ replies: [] }); + (0, import_react9.useEffect)(() => { -+ if (done) return void 0; -+ let stop = false; -+ let timer; -+ let seen = ""; -+ const tick = async () => { -+ const result = await actions.roomProgress(sessionId); -+ if (stop) return; -+ const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; + if (done) return void 0; + let stop = false; + let timer; +@@ -4341,10 +6934,10 @@ + const result = await actions.roomProgress(sessionId); + if (stop) return; + const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; + const key = entry ? JSON.stringify(entry.replies) : ""; -+ if (entry && key !== seen) { -+ seen = key; + if (entry && key !== seen) { + seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); + setLive({ replies: entry.replies ?? [] }); } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); -- }; -- return (0, import_react5.createElement)( -+ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -+ }; -+ void tick(); -+ return () => { -+ stop = true; -+ clearTimeout(timer); -+ }; -+ }, [done, sessionId, callId]); -+ let replies = live.replies; -+ if (done) { -+ try { -+ replies = JSON.parse(contentText(content)).replies ?? []; -+ } catch { -+ replies = []; -+ } -+ } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; +@@ -4362,71 +6955,89 @@ + replies = []; + } + } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; + if (replies.length === 0) return null; -+ const room2 = roster.roomsById[sessionId]; + const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ { className: "bt-group-replies" }, -+ replies.map((reply, index) => { + { className: "bt-group-replies" }, + replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; + const stored = roster.byId[reply.botId]; + const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; + const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -+ if (reply.kind === "user") { + if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { + return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react9.createElement)( @@ -6569,45 +5704,67 @@ diff --git a/client.js b/client.js + ), + room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null + ); -+ } -+ if (reply.kind === "failed") { + } + if (reply.kind === "failed") { +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", + "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, + { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, -+ t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -+ ); -+ } -+ if (reply.kind === "lookup") { + t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) + ); + } + if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, -+ reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -+ ); -+ } -+ if (reply.kind === "missed") { + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, + reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) + ); + } + if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -+ } -+ if (reply.kind === "resumed") { + } + if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -+ } -+ if (reply.kind === "cut") { + } + if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -+ } -+ if (reply.kind === "approval" || reply.kind === "working") { + } + if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( + return (0, import_react9.createElement)( -+ "div", -+ { key: `${reply.botId}-${index}`, className: "bt-event" }, + "div", + { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( + (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, + "button", + { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -+ ) -+ ); -+ } -+ if (reply.kind === "stopped") { + ) + ); + } + if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( +- "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { @@ -6619,372 +5776,178 @@ diff --git a/client.js b/client.js + key: `${reply.botId || reply.handle}-${index}`, + source: { remote: { handle: reply.handle || reply.botId }, senderName: reply.name || reply.who, senderSessionId: reply.botId }, + text: remoteMessageText(reply.text), -+ roster, -+ actions, -+ sessionId, -+ msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, -+ useUi -+ }); -+ } -+ return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); -+ }) - ); - } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -- ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null -- ); -+var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); -+var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); -+function callTree(root) { -+ const calls = []; -+ const seen = /* @__PURE__ */ new Set(); ++ roster, ++ actions, ++ sessionId, ++ msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, ++ useUi ++ }); ++ } ++ return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); + }) + ); + } +@@ -4435,52 +7046,52 @@ + function callTree(root) { + const calls = []; + const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; + const visit = (call4) => { + const id = call4.callId ?? call4; -+ if (seen.has(id)) return; -+ seen.add(id); + if (seen.has(id)) return; + seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); -+ }; -+ visit(root); -+ return calls; + }; + visit(root); + return calls; } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -+function useShownCards(actions, sessionId, callIds) { -+ const key = JSON.stringify(callIds); + function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { + (0, import_react9.useLayoutEffect)(() => { -+ const ids = JSON.parse(key); -+ if (ids.length === 0 || !actions.noteCards) return void 0; -+ actions.noteCards(sessionId, ids, 1); -+ return () => actions.noteCards(sessionId, ids, -1); -+ }, [sessionId, key]); -+} + const ids = JSON.parse(key); + if (ids.length === 0 || !actions.noteCards) return void 0; + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); + } +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; + const { text: arg, value: argValue } = toolArgs(call4); -+ const self = roster.byId[sessionId]; -+ switch (tool) { -+ case "message_bot": { -+ const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + const self = roster.byId[sessionId]; + switch (tool) { + case "message_bot": { + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react9.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "stop_bot": { -+ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + } + case "stop_bot": { + const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); + if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); + return (0, import_react9.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -+ } -+ case "create_bot": { -+ const created = findBot(arg("name")); -+ if (!done || created === void 0) return null; + } + case "create_bot": { + const created = findBot(arg("name")); + if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); + return (0, import_react9.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -+ } -+ case "update_bot": { -+ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); + } + case "update_bot": { + const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; + return done && target ? (0, import_react9.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; -+ } -+ case "group_relay": + } + case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); + return (0, import_react9.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); -+ case "create_group": -+ case "update_group": -+ case "delete_group": -+ case "post_to_group": { -+ const verbs = { -+ create_group: ["Created group", "Couldn't create the group"], -+ update_group: ["Updated group", "Couldn't update the group"], -+ delete_group: ["Deleted group", "Couldn't delete the group"], -+ post_to_group: ["Posted in", "Couldn't post in the group"] -+ }[tool]; -+ if (!done) return null; + case "create_group": + case "update_group": + case "delete_group": +@@ -4492,64 +7103,90 @@ + post_to_group: ["Posted in", "Couldn't post in the group"] + }[tool]; + if (!done) return null; +- const result = contentText(call2.content); + const result = contentText(call4.content); -+ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); + const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); + if (call4.isError || !named) return (0, import_react9.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -+ const name = named[2]; -+ const room2 = roster.rooms.find((entry) => entry.name === name); + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); + return (0, import_react9.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } -+ case "ask_user": { -+ const question = roster.questions?.[sessionId]; + } + case "ask_user": { + const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); + if (!question || question.callId !== call4.callId) return (0, import_react9.createElement)("span", { className: "bt-q-gone", hidden: true }); + return (0, import_react9.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); -+ } -+ case "request_secret": + } + case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); + return (0, import_react9.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); -+ case "ask_user_question": { -+ if (!done) return null; -+ let parsed = null; -+ try { + case "ask_user_question": { + if (!done) return null; + let parsed = null; + try { +- parsed = JSON.parse(contentText(call2.content)); + parsed = JSON.parse(contentText(call4.content)); -+ } catch { -+ parsed = null; + } catch { + parsed = null; } -- }); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -- ) -- ); -+ const answer = parsed?.answers?.[0]; -+ const question = argValue("questions")?.[0]; -+ if (!answer) return null; + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); + return (0, import_react9.createElement)("div", { className: "bt-q-inline" }, (0, import_react9.createElement)(SettledQuestion, { question, answer })); -+ } -+ case "read_group_chat": { -+ if (!done) return null; -+ const room2 = roster.rooms.find((entry) => entry.name === arg("group")); + } + case "read_group_chat": { + if (!done) return null; + const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; + return room2 ? (0, import_react9.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -+ } -+ case "remember": -+ case "forget": + } + case "remember": + case "forget": +- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; + return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; -+ default: -+ return null; -+ } + default: + return null; + } } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; +-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const root = node.data.root; -+ const turn = node.location?.turn?.turn ?? root.turn; -+ const hidden = isHiddenTurn(roster, sessionId, turn); + const roster = useRoster((value) => value); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); +- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); + const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); -+ const calls = hidden ? [] : callTree(root); + const calls = hidden ? [] : callTree(root); +- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); +- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); + const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); + const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); -+ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -+ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); + const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; + useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); +- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); + useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); -+ if (drawn.length === 0) return null; + if (drawn.length === 0) return null; +- const env = { sessionId, roster, actions, useUi, useWork, findBot }; +- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); - } --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; - --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); ++} ++ +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; - } --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); - --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) -- ); ++} ++ +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; - } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } ++} + +// src/client/activity-row-host.js +var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; @@ -6994,19 +5957,21 @@ diff --git a/client.js b/client.js + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); -+} -+ -+// src/client/activity-row.js + } + + // src/client/activity-row.js +-var import_react7 = require("react"); +var import_react10 = require("react"); -+var import_react_dom2 = require("react-dom"); -+var PHRASE_HOLD_MS = 800; -+var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -+function elapsedLabel(ms) { -+ const minutes = Math.floor(ms / 6e4); -+ if (minutes < 60) return `${Math.max(1, minutes)}m`; -+ const hours = Math.floor(minutes / 60); -+ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+} + 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 +7196,69 @@ + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; + } +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +var REMOTE_GRACE_MS = 1500; +function useAwaitingReply(roster, sessionId) { + const at = roster?.remoteAwaiting?.[sessionId]; @@ -7054,245 +6019,61 @@ diff --git a/client.js b/client.js + const check = () => { + frame = 0; + if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); - }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); + }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + check(); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- }, [open]); ++ }; + }, [remote, sending]); + return replied; - } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; ++} +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react10.useState)(null); + (0, import_react10.useEffect)(() => { -+ if (!active) { -+ setHost(null); -+ return void 0; - } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -+ let frame = 0; -+ const find = () => { -+ frame = 0; + if (!active) { + setHost(null); + return void 0; +@@ -4569,7 +7266,7 @@ + let frame = 0; + const find = () => { + frame = 0; +- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); + const node = findHost(where); -+ setHost((prev) => prev === node ? prev : node); -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(find); - }); -- }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); -- }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null -- ); --} --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) -- ); --} --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); -+ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -+ find(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, [active, where]); -+ return host; - } --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -+function useRowEntrances(replacing) { -+ const check = (0, import_react10.useRef)(replacing); -+ check.current = replacing; -+ (0, import_react10.useEffect)(() => { -+ const seen = /* @__PURE__ */ new WeakSet(); -+ const started = Date.now(); -+ let column = null; -+ let frame = 0; -+ const settle = (event) => { -+ if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -+ delete event.currentTarget.dataset.btEnter; -+ event.currentTarget.removeEventListener("animationend", settle); -+ }; -+ const scan = () => { -+ frame = 0; -+ const next = document.querySelector("[data-chat-flow]"); -+ if (next !== column) { -+ observer.disconnect(); -+ column = next; -+ if (column) observer.observe(column, { childList: true, subtree: true }); -+ } -+ if (!column) return; -+ const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -+ let last = -1; -+ items.forEach((item, index) => { -+ if (seen.has(item)) last = index; - }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -- ); --} --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); -+ const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -+ for (const item of fresh) seen.add(item); -+ if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -+ for (const item of fresh) { -+ if (items.indexOf(item) < last) continue; -+ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -+ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -+ item.addEventListener("animationend", settle); -+ } -+ }; -+ const observer = new MutationObserver(() => { -+ if (!frame) frame = requestAnimationFrame(scan); -+ }); -+ scan(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ }; -+ }, []); + setHost((prev) => prev === node ? prev : node); + }; + const observer = new MutationObserver(() => { +@@ -4581,13 +7278,13 @@ + cancelAnimationFrame(frame); + observer.disconnect(); + }; +- }, [active]); ++ }, [active, where]); + return host; } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( -+function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -+ const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -+ 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]; + function useRowEntrances(replacing) { +- const check = (0, import_react7.useRef)(replacing); ++ const check = (0, import_react10.useRef)(replacing); + check.current = replacing; +- (0, import_react7.useEffect)(() => { ++ (0, import_react10.useEffect)(() => { + const seen = /* @__PURE__ */ new WeakSet(); + const started = Date.now(); + let column = null; +@@ -4638,21 +7335,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]; +- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); + const entries2 = (0, import_react10.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -+ const streaming = entries2.some((entry) => entry.text); -+ const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -+ const relaying = tools.some((entry) => entry.relay); + const streaming = entries2.some((entry) => entry.text); + const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); + const relaying = tools.some((entry) => entry.relay); +- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); + const awaiting = useAwaitingReply(roster, sessionId); + const sendRun = (0, import_react10.useRef)(false); + if (!running) sendRun.current = false; @@ -7303,37 +6084,49 @@ diff --git a/client.js b/client.js + const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; + lastRun.current = { running, send: sendRun.current }; + const steady = bridging; -+ const tool = tools.at(-1); + const tool = tools.at(-1); +- const inputRef = (0, import_react7.useRef)(0); + const inputRef = (0, import_react10.useRef)(0); -+ const inputAt = Math.max(inputRef.current, pendingInput); -+ inputRef.current = inputAt; + const inputAt = Math.max(inputRef.current, pendingInput); + inputRef.current = inputAt; +- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; +- const [shown, setShown] = (0, import_react7.useState)(null); + const phrase = visible ? remoteApprovalPhrase(bot?.remote !== void 0, { tool, inputAt }) : null; + const want = !visible ? null : phrase ? { ...phrase, label: t(phrase.label) } : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const [shown, setShown] = (0, import_react10.useState)(null); -+ const wantKey = want?.key ?? null; + const wantKey = want?.key ?? null; +- const markState = (0, import_react7.useRef)("thinking"); + const markState = (0, import_react10.useRef)("thinking"); -+ if (want) markState.current = want.state; + if (want) markState.current = want.state; +- (0, import_react7.useEffect)(() => { + (0, import_react10.useEffect)(() => { -+ 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; -+ if (wait <= 0) { -+ make(); -+ return void 0; -+ } -+ const timer = setTimeout(make, wait); -+ return () => clearTimeout(timer); -+ }, [wantKey, shown]); + 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 +7370,31 @@ + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); +- const [phase, setPhase] = (0, import_react7.useState)(null); +- const endedAt = (0, import_react7.useRef)(0); +- const shift = (0, import_react7.useRef)(null); +- const leaving = (0, import_react7.useRef)(false); + const [phase, setPhase] = (0, import_react10.useState)(null); + const endedAt2 = (0, import_react10.useRef)(0); + const shift = (0, import_react10.useRef)(null); + const leaving = (0, import_react10.useRef)(false); -+ leaving.current = phase !== null && !visible; + leaving.current = phase !== null && !visible; +- (0, import_react7.useEffect)(() => { + (0, import_react10.useEffect)(() => { -+ if (visible) { -+ setPhase("in"); -+ return void 0; -+ } + if (visible) { + setPhase("in"); + return void 0; + } +- setPhase((current) => current === "in" ? "out" : current); +- const timer = setTimeout(() => { +- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; +- setPhase((current) => current === "out" ? null : current); +- }, 140); + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -7344,29 +6137,35 @@ diff --git a/client.js b/client.js + }; + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); -+ return () => clearTimeout(timer); -+ }, [visible]); + return () => clearTimeout(timer); + }, [visible]); +- (0, import_react7.useLayoutEffect)(() => { + (0, import_react10.useLayoutEffect)(() => { -+ if (phase !== null) return; + if (phase !== null) return; +- endedAt.current = Date.now(); + endedAt2.current = Date.now(); -+ setShown(null); -+ const before = shift.current; -+ shift.current = null; -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (before === null || !scroller || reducedMotion()) return; -+ 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]); + setShown(null); + const before = shift.current; + shift.current = null; +@@ -4691,31 +7403,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]); +- (0, import_react7.useEffect)(() => { + (0, import_react10.useEffect)(() => { -+ if (!running) actions.clearLive(sessionId); -+ }, [running, sessionId]); + if (!running) actions.clearLive(sessionId); + }, [running, sessionId]); +- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); +- const [now, setNow] = (0, import_react7.useState)(Date.now); +- (0, import_react7.useEffect)(() => { + useRowEntrances(() => leaving.current || Date.now() - endedAt2.current < 300); + const [now, setNow] = (0, import_react10.useState)(Date.now); + (0, import_react10.useEffect)(() => { -+ if (phase !== "in") return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 1e4); -+ return () => clearInterval(timer); -+ }, [phase]); + if (phase !== "in") return void 0; + const timer = setInterval(() => setNow(Date.now()), 1e4); + return () => clearInterval(timer); + }, [phase]); +- const host = useRunningHost(running); + const remote = bot?.remote !== void 0; + const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); + const drawn = (0, import_react10.useRef)(false); @@ -7374,200 +6173,52 @@ diff --git a/client.js b/client.js + (0, import_react10.useEffect)(() => { + drawn.current = Boolean(host && phase !== null && shown); + }, [host, phase, shown]); -+ if (!host || phase === null || !shown) return null; -+ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; +- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( + return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( +- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, +- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), +- (0, import_react7.createElement)( + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react10.createElement)( "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -- ); --} --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null -- ); --} --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) -- ); -+ { className: "bt-typing-text" }, + { className: "bt-typing-text" }, +- (0, import_react7.createElement)( + (0, import_react10.createElement)( -+ "span", -+ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + "span", + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, +- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), +- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) -+ ), host); - } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); -+ -+// src/client/dsh-offer.js -+var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); -+var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; -+var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); -+function showsDshRow(dsh, app) { -+ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -+ if (dsh.kind === "npm") return true; -+ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); + ) + ) + ), host); +@@ -4731,49 +7449,1697 @@ + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; -+ + +// src/client/overlay-hooks.js +var import_react11 = require("react"); +var escapeLayers = []; +var onEscapeKey = (event) => { + if (event.key === "Escape") escapeLayers.at(-1)?.current(); - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); ++}; +function useEscape(onClose) { + const ref = (0, import_react11.useRef)(onClose); + ref.current = onClose; + (0, import_react11.useEffect)(() => { + if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); + escapeLayers.push(ref); - return () => { -- alive.current = false; ++ return () => { + escapeLayers.splice(escapeLayers.indexOf(ref), 1); + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); - }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -- } -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) -- ); ++ }; + }, []); - } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++} +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -7597,74 +6248,12 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; - }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -- } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } - } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++ }); ++} +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); - } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; ++} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); @@ -7675,11 +6264,8 @@ diff --git a/client.js b/client.js + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; - }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; - } --function Nothing() { -- return null; ++ }); ++} +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -7707,17 +6293,7 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text2)), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -7734,12 +6310,7 @@ diff --git a/client.js b/client.js + ), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++} +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -7762,17 +6333,7 @@ diff --git a/client.js b/client.js + rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); - } -- return next === void 0 ? void 0 : turns[next].first; --} --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++ } + return (0, import_react12.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react12.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -7783,31 +6344,7 @@ diff --git a/client.js b/client.js + now, + onOpen: () => actions.openSession(item.room.id) + }))); - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -- ); ++} +function useFlowColumn(ref, active, key) { + (0, import_react12.useLayoutEffect)(() => { + const node = ref.current; @@ -7824,9 +6361,7 @@ diff --git a/client.js b/client.js + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); - } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); ++} +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -7835,67 +6370,12 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [active]); + return now; - } --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] --}; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] ++} +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], + ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] - ]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -- } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -- } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; ++]; +function ApprovalCard({ approval, bot, actions }) { + const [busy, setBusy] = (0, import_react12.useState)(false); + const answer = (choice) => { @@ -7931,7 +6411,7 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), + entry.approvals.map((approval) => (0, import_react12.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) + ); - } ++ } + if (entry.state !== "work") return null; + const stop = () => { + setStopping(true); @@ -7947,23 +6427,13 @@ diff --git a/client.js b/client.js + entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null + ) + ); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++} +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); ++} +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -8048,26 +6518,7 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -- } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); ++} + +// src/client/inbox.js +var import_react13 = require("react"); @@ -8124,19 +6575,11 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); - return () => { -- stop = true; ++ return () => { + stopped = true; - clearTimeout(timer); ++ clearTimeout(timer); + taskListeners.delete(setTasks); - }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; ++ }; + }, []); + return tasks; +} @@ -8157,88 +6600,17 @@ diff --git a/client.js b/client.js + }); + load(); + workflowPoll = setInterval(load, POLL_MS2); - } -- } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); -- } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); ++ } + return () => { + workflowListeners.delete(setWorkflows); + if (workflowListeners.size === 0 && workflowPoll !== null) { + clearInterval(workflowPoll); + workflowPoll = null; - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( -- "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); -- }) -- ); ++ } + }; + }, []); + return workflows; - } --var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); --var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); --function callTree(root) { -- const calls = []; ++} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -8352,13 +6724,7 @@ diff --git a/client.js b/client.js + const count = room2.pendingCount ?? 0; + if (count > 0) held(room2.id, count, { room: room2, place: placeOf(roster, room2.id), ...people(roster, null) }); + } - const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); ++ const seen = /* @__PURE__ */ new Set(); + const running = (botId, entry, roomId) => { + const bot = ownBot(roster, botId); + const key = `${botId}|${roomId ?? ""}`; @@ -8376,43 +6742,7 @@ diff --git a/client.js b/client.js + chat: roomId ?? bot.id, + ...people(roster, bot.id) + }); - }; -- visit(root); -- return calls; --} --function useShownCards(actions, sessionId, callIds) { -- const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { -- const ids = JSON.parse(key); -- if (ids.length === 0 || !actions.noteCards) return void 0; -- actions.noteCards(sessionId, ids, 1); -- return () => actions.noteCards(sessionId, ids, -1); -- }, [sessionId, key]); --} --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); -- const self = roster.byId[sessionId]; -- switch (tool) { -- case "message_bot": { -- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "stop_bot": { -- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); -- } -- case "create_bot": { -- const created = findBot(arg("name")); -- if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); -- } -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; ++ }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -8462,26 +6792,7 @@ diff --git a/client.js b/client.js + const column = columnOf(task); + if (column === "in-progress") progress.push({ ...card, status: "task", since: startedAtOf(task) }); + else if (column === "done" && fresh(task.updatedAt)) done.push({ ...card, status: task.status === "cancelled" ? "cancelled" : "done" }); - } -- case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); -- case "create_group": -- case "update_group": -- case "delete_group": -- case "post_to_group": { -- const verbs = { -- create_group: ["Created group", "Couldn't create the group"], -- update_group: ["Updated group", "Couldn't update the group"], -- delete_group: ["Deleted group", "Couldn't delete the group"], -- post_to_group: ["Posted in", "Couldn't post in the group"] -- }[tool]; -- if (!done) return null; -- const result = contentText(call2.content); -- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); -- const name = named[2]; -- const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); ++ } + } + for (const graph of Array.isArray(workflows) ? workflows : []) { + if (graph === null || typeof graph !== "object") continue; @@ -8493,11 +6804,7 @@ diff --git a/client.js b/client.js + const at2 = endedAt(run); + if (!fresh(at2)) continue; + if (latest === void 0 || at2 > endedAt(latest)) latest = run; - } -- case "ask_user": { -- const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); ++ } + if (latest === void 0) continue; + const at = endedAt(latest); + const name = typeof graph.name === "string" && graph.name.trim() !== "" ? graph.name.trim() : String(graph.id ?? t("A workflow")); @@ -8526,16 +6833,7 @@ diff --git a/client.js b/client.js + const text2 = previewOf(sessions, part, [], [], botNames(roster)); + if (text2 === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text2, place: "", chat: bot.id, ...people(roster, bot.id) }); - } -- case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); -- case "ask_user_question": { -- if (!done) return null; -- let parsed = null; -- try { -- parsed = JSON.parse(contentText(call2.content)); -- } catch { -- parsed = null; ++ } + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { @@ -8622,11 +6920,7 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); - } -- const answer = parsed?.answers?.[0]; -- const question = argValue("questions")?.[0]; -- if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); ++ } + }) : null, + slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + slot.open = true; @@ -8651,11 +6945,7 @@ diff --git a/client.js b/client.js + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); + return; - } -- case "read_group_chat": { -- if (!done) return null; -- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++ } + if (custom === "") return; + onAnswer(card, custom); + }; @@ -8664,12 +6954,7 @@ diff --git a/client.js b/client.js + if (!multi) { + onAnswer(card, { selected: [label], custom: "" }); + return; - } -- case "remember": -- case "forget": -- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; -- default: -- return null; ++ } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); + }; @@ -8811,7 +7096,7 @@ diff --git a/client.js b/client.js + if (card?.kind === "workflow") { + actions.openSettings("workflow"); + return; - } ++ } + actions.openSession(card.chat); +} +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { @@ -8955,22 +7240,7 @@ diff --git a/client.js b/client.js + id: String(task.id), + title: String(task.title ?? "") + }; - } --function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { -- const roster = useRoster((value) => value); -- const root = node.data.root; -- const turn = node.location?.turn?.turn ?? root.turn; -- const hidden = isHiddenTurn(roster, sessionId, turn); -- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); -- const calls = hidden ? [] : callTree(root); -- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); -- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); -- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); -- if (drawn.length === 0) return null; -- const env = { sessionId, roster, actions, useUi, useWork, findBot }; -- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); ++} + +// src/client/board-card.js +var import_react14 = require("react"); @@ -9033,18 +7303,8 @@ diff --git a/client.js b/client.js + footer, + actions + ); - } - --// src/client/activity-row.js --var import_react7 = require("react"); --var import_react_dom2 = require("react-dom"); --var PHRASE_HOLD_MS = 800; --var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); --function elapsedLabel(ms) { -- const minutes = Math.floor(ms / 6e4); -- if (minutes < 60) return `${Math.max(1, minutes)}m`; -- const hours = Math.floor(minutes / 60); -- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; ++} ++ +// src/client/kanban.js +var import_react15 = require("react"); +var POLL_MS3 = 15e3; @@ -9056,13 +7316,7 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), + (0, import_react15.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) + ); - } --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); -- (0, import_react7.useEffect)(() => { -- if (!active) { -- setHost(null); -- return void 0; ++} +function shortId(id) { + const hex = String(id ?? "").replace(/^task-/, ""); + return `#${hex.slice(-6)}`; @@ -9128,12 +7382,7 @@ diff --git a/client.js b/client.js + for (const bot of children.get(parent) ?? []) { + rows.push({ kind: "bot", id: bot.id, name: bot.name, depth, bot }); + walk(bot.id, depth + 1); - } -- let frame = 0; -- const find = () => { -- frame = 0; -- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); -- setHost((prev) => prev === node ? prev : node); ++ } + }; + walk("", 0); + const groups = (roster?.rooms ?? []).filter((room2) => room2 && room2.hidden !== true && room2.origin !== "face-to-face" && adminOf(roster, room2)).map((room2) => ({ kind: "group", id: room2.id, name: room2.displayName || room2.name || t("Group"), room: room2 })); @@ -9219,22 +7468,13 @@ diff --git a/client.js b/client.js + if (stopped || !Array.isArray(list)) return; + setArchive(list.filter((task) => columnOf(task) === "done")); + }).catch(() => void 0); - }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(find); -- }); -- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); -- find(); ++ }; + load(); + const timer = setInterval(load, POLL_MS3); - return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); ++ return () => { + stopped = true; + clearInterval(timer); - }; -- }, [active]); -- return host; ++ }; + }, [key]); + const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const replace = (task) => { @@ -9245,42 +7485,7 @@ diff --git a/client.js b/client.js + }; + const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + return { own, tasks, archive: done, replace }; - } --function useRowEntrances(replacing) { -- const check = (0, import_react7.useRef)(replacing); -- check.current = replacing; -- (0, import_react7.useEffect)(() => { -- const seen = /* @__PURE__ */ new WeakSet(); -- const started = Date.now(); -- let column = null; -- let frame = 0; -- const settle = (event) => { -- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; -- delete event.currentTarget.dataset.btEnter; -- event.currentTarget.removeEventListener("animationend", settle); -- }; -- const scan = () => { -- frame = 0; -- const next = document.querySelector("[data-chat-flow]"); -- if (next !== column) { -- observer.disconnect(); -- column = next; -- if (column) observer.observe(column, { childList: true, subtree: true }); -- } -- if (!column) return; -- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; -- let last = -1; -- items.forEach((item, index) => { -- if (seen.has(item)) last = index; -- }); -- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); -- for (const item of fresh) seen.add(item); -- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; -- for (const item of fresh) { -- if (items.indexOf(item) < last) continue; -- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); -- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; -- item.addEventListener("animationend", settle); ++} +function RailButton({ row, selected, meta, onSelect }) { + return (0, import_react15.createElement)( + "button", @@ -9373,17 +7578,7 @@ diff --git a/client.js b/client.js + const id = event.dataTransfer?.getData("text/plain"); + if (id) onMove?.(id, column); + onDrag?.(null); - } -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); -- }); -- scan(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- }; -- }, []); ++ } + }, + (0, import_react15.createElement)( + "h3", @@ -9411,35 +7606,7 @@ diff --git a/client.js b/client.js + lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + ); - } --function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -- const roster = useRoster((value) => value); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -- 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]; -- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -- const streaming = entries2.some((entry) => entry.text); -- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -- const relaying = tools.some((entry) => entry.relay); -- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); -- const tool = tools.at(-1); -- const inputRef = (0, import_react7.useRef)(0); -- const inputAt = Math.max(inputRef.current, pendingInput); -- inputRef.current = inputAt; -- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -- const [shown, setShown] = (0, import_react7.useState)(null); -- const wantKey = want?.key ?? null; -- const markState = (0, import_react7.useRef)("thinking"); -- if (want) markState.current = want.state; -- (0, import_react7.useEffect)(() => { -- 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; -- if (wait <= 0) { -- make(); -- return void 0; ++} +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; @@ -9555,19 +7722,7 @@ diff --git a/client.js b/client.js + replace(await actions.taskUpdate({ id, column })); + } catch (failure) { + setError(failure?.message ?? String(failure)); - } -- const timer = setTimeout(make, wait); -- return () => clearTimeout(timer); -- }, [wantKey, shown]); -- const [phase, setPhase] = (0, import_react7.useState)(null); -- const endedAt = (0, import_react7.useRef)(0); -- const shift = (0, import_react7.useRef)(null); -- const leaving = (0, import_react7.useRef)(false); -- leaving.current = phase !== null && !visible; -- (0, import_react7.useEffect)(() => { -- if (visible) { -- setPhase("in"); -- return void 0; ++ } + }; + const create = async (title) => { + const text2 = String(title ?? "").trim(); @@ -9578,54 +7733,11 @@ diff --git a/client.js b/client.js + setDraft(""); + } catch (failure) { + setError(failure?.message ?? String(failure)); - } -- setPhase((current) => current === "in" ? "out" : current); -- const timer = setTimeout(() => { -- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -- setPhase((current) => current === "out" ? null : current); -- }, 140); -- return () => clearTimeout(timer); -- }, [visible]); -- (0, import_react7.useLayoutEffect)(() => { -- if (phase !== null) return; -- endedAt.current = Date.now(); -- setShown(null); -- const before = shift.current; -- shift.current = null; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (before === null || !scroller || reducedMotion()) return; -- 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]); -- (0, import_react7.useEffect)(() => { -- if (!running) actions.clearLive(sessionId); -- }, [running, sessionId]); -- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); -- const [now, setNow] = (0, import_react7.useState)(Date.now); -- (0, import_react7.useEffect)(() => { -- if (phase !== "in") return void 0; -- const timer = setInterval(() => setNow(Date.now()), 1e4); -- return () => clearInterval(timer); -- }, [phase]); -- const host = useRunningHost(running); -- if (!host || phase === null || !shown) return null; -- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; -- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( ++ } + }; + const name = scopeName(roster, scope); + return (0, import_react15.createElement)( - "div", -- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, -- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -- (0, import_react7.createElement)( -- "span", -- { className: "bt-typing-text" }, -- (0, import_react7.createElement)( -- "span", -- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), -- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -- ) ++ "div", + { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, + (0, import_react15.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react15.createElement)( @@ -9665,19 +7777,9 @@ diff --git a/client.js b/client.js + onDrag: setDragging, + onCreate: create + }) - ) -- ), host); ++ ) + ); - } -- --// src/client/dsh-offer.js --var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); --var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; --var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); --function showsDshRow(dsh, app) { -- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -- if (dsh.kind === "npm") return true; -- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++} +var KANBAN_CSS = ` +.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9739,9 +7841,9 @@ diff --git a/client.js b/client.js + .bt-mem.bt-board{width:100vw;border-radius:0;border:0} + .bt-board-columns{flex-direction:column} + .bt-board-column{min-width:0} - } ++} +`; - ++ // src/client/sidebar.js -var import_react8 = require("react"); +var import_react16 = require("react"); @@ -9806,7 +7908,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 +9151,131 @@ onClick: () => actions.openSession(id), onContextMenu }, @@ -9953,7 +8055,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 +9283,20 @@ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, @@ -9982,7 +8084,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 +9317,10 @@ } })(); }; @@ -9995,7 +8097,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 +9333,7 @@ }) ); } @@ -10004,7 +8106,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 +9347,8 @@ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -10015,7 +8117,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 +9383,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; @@ -10028,7 +8130,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 +9401,30 @@ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, @@ -10070,7 +8172,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 +9438,8 @@ } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { @@ -10081,7 +8183,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 +9456,44 @@ tops.current = { layout, map: next }; }, [layout, key]); } @@ -10127,7 +8229,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 +9502,26 @@ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); @@ -10158,7 +8260,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 +9531,42 @@ event.preventDefault(); actions.openPalette(event.key); }; @@ -10221,7 +8323,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 +9605,14 @@ setDshError(""); void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); }; @@ -10241,7 +8343,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 +9622,21 @@ "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle @@ -10274,7 +8376,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 +9644,17 @@ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, @@ -10299,7 +8401,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 +9665,25 @@ ), document.body) : null ); } @@ -10371,7 +8473,7 @@ diff --git a/client.js b/client.js ); } var openShellSettings = null; -@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { +@@ -5259,6 +9712,18 @@ else openHarnessSettings(); }); } @@ -10390,7 +8492,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 +9753,32 @@ })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { @@ -10431,7 +8533,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 +9806,32 @@ const groups = [ [ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], @@ -10476,7 +8578,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 +9839,7 @@ setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -10485,7 +8587,7 @@ diff --git a/client.js b/client.js )) ), document.body) : null ); -@@ -5382,8 +5382,8 @@ function useLandOnMainBot(roster, useSessions, actions) { +@@ -5382,8 +9849,8 @@ const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); @@ -10496,7 +8598,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 +9864,40 @@ return void 0; } const last = lastBot.current; @@ -10547,7 +8649,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 +9923,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)} @@ -10556,7 +8658,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 +9958,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}} @@ -10570,10 +8672,8 @@ diff --git a/client.js b/client.js +.bt-row-wrap:hover .bt-row-settings,.bt-row-wrap:focus-within .bt-row-settings{opacity:1;pointer-events:auto;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings:hover{opacity:1;color:var(--bt-ink-2);background:var(--bt-hover)} +.bt-row-settings svg{width:15px;height:15px} - `; - --// src/client/header.js --var import_react9 = require("react"); ++`; ++ +// src/client/busy-ack-logic.js +function busyAckText(hostAck) { + return typeof hostAck?.text === "string" ? hostAck.text.trim() : ""; @@ -10602,8 +8702,7 @@ diff --git a/client.js b/client.js + +// src/client/busy-ack.js +var import_react17 = require("react"); - var import_react_dom4 = require("react-dom"); --var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); ++var import_react_dom4 = require("react-dom"); +function useRunningHost2(active) { + const [host, setHost] = (0, import_react17.useState)(null); + (0, import_react17.useEffect)(() => { @@ -10685,9 +8784,12 @@ diff --git a/client.js b/client.js +.bt-busy-dots>span:nth-child(2){animation-delay:.2s}.bt-busy-dots>span:nth-child(3){animation-delay:.4s} +@keyframes bt-busy-blink{0%,80%,100%{opacity:.25}40%{opacity:1}} +@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}} -+`; -+ -+// src/client/header.js + `; + + // src/client/header.js +-var import_react9 = require("react"); +-var import_react_dom4 = require("react-dom"); +-var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); +var import_react18 = require("react"); +var import_react_dom5 = require("react-dom"); +var GoArrow = () => (0, import_react18.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react18.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); @@ -10702,7 +8804,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 +10101,19 @@ return shown; } function MemoryNotice({ shown }) { @@ -10728,7 +8830,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 +10126,7 @@ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); @@ -10737,7 +8839,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 +10137,41 @@ const bot = roster.byId[sessionId]; const room2 = roster.roomsById[sessionId]; const detailsOpen = useUi((state) => state.details === sessionId); @@ -10809,7 +8911,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 +10220,7 @@ }, []); return view; } @@ -10818,7 +8920,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 +10236,22 @@ }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; @@ -10846,7 +8948,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 +10259,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"); @@ -10867,7 +8969,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 +10276,7 @@ event.stopPropagation(); onDismiss(); } @@ -10876,7 +8978,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 +10285,9 @@ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); @@ -10889,7 +8991,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 +10296,135 @@ ); } @@ -11033,7 +9135,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 +10443,10 @@ let interim = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const result = event.results[index]; @@ -11047,7 +9149,7 @@ diff --git a/client.js b/client.js } if (interim) latest.current(interim, false); }; -@@ -5774,19 +5774,38 @@ function useDictation(onText) { +@@ -5774,19 +10472,38 @@ const stop = () => recognition.current?.stop(); return { listening, note, start, stop }; } @@ -11091,7 +9193,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 +10514,10 @@ }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; @@ -11105,7 +9207,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 +10525,16 @@ title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() @@ -11126,7 +9228,7 @@ diff --git a/client.js b/client.js ); } function menuIcons(roster) { -@@ -5825,17 +5825,17 @@ function menuIcons(roster) { +@@ -5825,17 +10542,17 @@ return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; @@ -11149,7 +9251,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 +10593,10 @@ actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); @@ -11163,7 +9265,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 +10606,8 @@ "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle @@ -11174,7 +9276,7 @@ diff --git a/client.js b/client.js "div", { ref: menu, -@@ -5900,9 +5900,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { +@@ -5900,9 +10617,9 @@ style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, @@ -11187,7 +9289,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -5922,15 +5922,57 @@ var conversationOf = (roster, sessionId) => { +@@ -5922,15 +10639,57 @@ function mentionRows(roster, sessionId, query = "") { const here = conversationOf(roster, sessionId); if (here === null) return null; @@ -11249,7 +9351,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 +10718,31 @@ order: -20, showGroupTitle: false, async candidates(session, { query }) { @@ -11285,7 +9387,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 +10771,83 @@ return [bots, chats]; } @@ -11369,7 +9471,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 +10861,8 @@ function waveBytes(samples, rate = 16e3) { const bytes = new Uint8Array(44 + samples.length * 2); const view = new DataView(bytes.buffer); @@ -11380,7 +9482,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 +10883,9 @@ return bytes; } function base64Of(bytes) { @@ -11393,7 +9495,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 +10974,9 @@ }; } @@ -11629,7 +9731,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 +10996,10 @@ }); } var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); @@ -11649,7 +9751,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 +11024,12 @@ ]; } function EffortRows({ reasoning, current, onPick }) { @@ -11667,7 +9769,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 +11038,17 @@ "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, @@ -11691,7 +9793,7 @@ diff --git a/client.js b/client.js "button", { key: model.ref, -@@ -6436,20 +6436,20 @@ function ModelRows({ roster, current, onPick }) { +@@ -6436,20 +11058,20 @@ title: model.ref, onClick: () => onPick(model.ref) }, @@ -11720,7 +9822,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 +11106,7 @@ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close }; } function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) { @@ -11728,7 +9830,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 +11134,10 @@ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; @@ -11742,7 +9844,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 +11150,38 @@ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, @@ -11795,7 +9897,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 +11193,13 @@ void actions.refreshModels?.(); }).finally(() => setBusy("")); }; @@ -11814,7 +9916,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 +11208,115 @@ "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) @@ -11825,33 +9927,10 @@ diff --git a/client.js b/client.js + (0, import_react20.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react20.createElement)(SlidersIcon), t("Manage models")) ]; - return (0, import_react13.createElement)( -+ return (0, import_react20.createElement)( -+ "div", -+ { className: "bt-model-dock" }, -+ (0, import_react20.createElement)( -+ "div", -+ { className: "bt-astack", "data-lead": "" }, -+ (0, import_react20.createElement)( -+ "div", -+ { className: "bt-msg" }, -+ (0, import_react20.createElement)( -+ "div", -+ { className: "bt-msg-line" }, -+ (0, import_react20.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react20.createElement)("div", { className: "bt-bubble" }, text2) -+ ) -+ ) -+ ), -+ (0, import_react20.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), -+ error ? (0, import_react20.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ ); -+} -+function RemoteGoneDock({ bot }) { + return (0, import_react20.createElement)( "div", -- { className: "bt-model-dock" }, + { className: "bt-model-dock" }, - (0, import_react13.createElement)( -+ { className: "bt-model-dock", "data-flybot": "remote-gone" }, + (0, import_react20.createElement)( "div", { className: "bt-astack", "data-lead": "" }, @@ -11866,12 +9945,33 @@ diff --git a/client.js b/client.js - (0, import_react13.createElement)(BotMark, { bot, size: 22 }), - (0, import_react13.createElement)("div", { className: "bt-bubble" }, text) + (0, import_react20.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react20.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ (0, import_react20.createElement)("div", { className: "bt-bubble" }, text2) ) ) -- ), + ), - (0, import_react13.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ (0, import_react20.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), ++ error ? (0, import_react20.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ ); ++} ++function RemoteGoneDock({ bot }) { ++ return (0, import_react20.createElement)( ++ "div", ++ { className: "bt-model-dock", "data-flybot": "remote-gone" }, ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": "" }, ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-msg" }, ++ (0, import_react20.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ (0, import_react20.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react20.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ ) ++ ) + ) ); } @@ -11948,7 +10048,7 @@ diff --git a/client.js b/client.js ...ops, ...hooks, t: actions.accountT(), -@@ -6639,6 +6639,16 @@ function SignInHost({ entry, actions }) { +@@ -6639,6 +11328,16 @@ }); } var MODEL_CSS = ` @@ -11965,7 +10065,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 +11374,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}} `; @@ -12070,7 +10170,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 +11496,35 @@ }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; @@ -12122,7 +10222,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 +11536,33 @@ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -12172,7 +10272,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 +11575,19 @@ title: value, onClick: toggle }, @@ -12198,7 +10298,7 @@ diff --git a/client.js b/client.js ), document.body) : null ); } -@@ -6830,8 +6830,8 @@ var FOLDER_CSS = ` +@@ -6830,8 +11607,8 @@ `; // src/client/schedules.js @@ -12209,7 +10309,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 +11616,8 @@ for (const listener of listeners) listener(view); } function useSchedules(actions) { @@ -12220,7 +10320,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 +11625,7 @@ if (!stopped) publish(next); }).catch(() => { }).finally(() => { @@ -12229,7 +10329,7 @@ diff --git a/client.js b/client.js }); load(); return () => { -@@ -6873,7 +6873,8 @@ function weekdayNames(days) { +@@ -6873,7 +11650,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 }); @@ -12239,7 +10339,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 +11665,8 @@ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`; case "weekly": { const days = (task.weekdays ?? []).join(); @@ -12250,7 +10350,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 +11674,25 @@ return ""; } } @@ -12281,7 +10381,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 +11705,49 @@ setBusy(true); void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false)); }; @@ -12340,7 +10440,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 +11755,7 @@ "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove @@ -12349,7 +10449,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 +11765,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"]]; @@ -12416,7 +10516,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 +11818,298 @@ setBusy(false); return; } @@ -12654,7 +10754,7 @@ diff --git a/client.js b/client.js + (0, import_react23.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; - } ++} +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; + const [copying, setCopying] = (0, import_react23.useState)(null); @@ -12703,7 +10803,7 @@ diff --git a/client.js b/client.js + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react23.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); -+} + } function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); + const [editing, setEditing] = (0, import_react23.useState)(null); @@ -12777,7 +10877,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 +12117,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} @@ -12790,7 +10890,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 +12154,12 @@ `; // src/client/capability-settings.js @@ -12806,7 +10906,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 +12168,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 }) { @@ -12821,7 +10921,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 +12184,7 @@ ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { @@ -12830,7 +10930,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 +12195,7 @@ if (draft.kind === "mcp" && draft.transport === "stdio") { payload.transport = "stdio"; payload.command = draft.command; @@ -12839,7 +10939,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 +12215,95 @@ } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); @@ -12975,7 +11075,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 +12326,20 @@ setConfirming(""); void run(actions.deleteCapability(item.name)); }; @@ -13003,7 +11103,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 +12355,10 @@ const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; @@ -13016,7 +11116,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 +12368,55 @@ ); } @@ -13074,7 +11174,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 +12426,13 @@ for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -13091,7 +11191,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 +12446,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 }); @@ -13114,7 +11214,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7428,45 +7428,45 @@ function ago(at, now) { +@@ -7428,45 +12470,45 @@ return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { @@ -13179,7 +11279,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -7478,9 +7478,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { +@@ -7478,9 +12520,9 @@ return groups; }, []); function EntryRow({ entry, remove }) { @@ -13192,7 +11292,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 +12537,16 @@ setBusy(true); if (!await remove(entry)) setBusy(false); }; @@ -13214,7 +11314,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 +12554,7 @@ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click @@ -13223,7 +11323,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 +12563,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 } ]; @@ -13341,7 +11441,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 +12648,9 @@ }, [botId]); return { view, error, load, show }; } @@ -13354,7 +11454,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 +12658,18 @@ } function MemoryDialog({ bot, actions, leaving = false }) { const { view, error, load, show } = useMemoryView(bot.id, actions); @@ -13383,7 +11483,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 +12713,7 @@ }; const ask = async (event) => { event.preventDefault(); @@ -13392,7 +11492,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,74 +12731,106 @@ 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; @@ -13485,10 +11585,10 @@ diff --git a/client.js b/client.js ) ) ) -@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { - ); - } - + ) ++ ); ++} ++ +// src/client/standing-approvals.js +var import_react27 = require("react"); +var standingOf = (target, roster) => { @@ -13518,16 +11618,16 @@ diff --git a/client.js b/client.js + onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind)) + }, t("Remove")) + )) -+ ); -+} -+ + ); + } + // src/client/bot-settings.js -var import_react18 = require("react"); +var import_react28 = require("react"); 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 +12854,8 @@ throw new Error(t("That image is too large")); } function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) { @@ -13538,7 +11638,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 +12866,7 @@ if (next === void 0 || next === (value ?? "") || required && next === "") return; void onSave(next); }; @@ -13547,7 +11647,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 +12887,29 @@ }); } function Row({ label, hint, stack = false, children }) { @@ -13586,7 +11686,7 @@ diff --git a/client.js b/client.js ); } function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { +@@ -7846,22 +12920,22 @@ return t("Applies from the next message."); } function ShapeRing({ shape, size }) { @@ -13614,7 +11714,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 +12943,8 @@ title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) @@ -13625,7 +11725,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 +12952,19 @@ title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) @@ -13651,7 +11751,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 +12982,7 @@ window.removeEventListener("keydown", onKey, true); }; }, [onClose]); @@ -13660,7 +11760,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 +12996,32 @@ }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); @@ -13701,7 +11801,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 +13037,15 @@ upload(event.dataTransfer.files?.[0]); } }, @@ -13723,7 +11823,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 +13056,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 }); @@ -13759,7 +11859,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,150 +8011,813 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,112 +13086,116 @@ if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -13872,55 +11972,65 @@ diff --git a/client.js b/client.js { className: "bt-set-row bt-bs-row" }, - (0, import_react18.createElement)( + (0, import_react28.createElement)( -+ "span", -+ { className: "bt-set-copy" }, + "span", + { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) -+ ), + ), +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) + (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) + ) : (0, import_react28.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) -+ ), -+ // The Main Bot tile is always first and always shown, like in the context menu. + ), + // The Main Bot tile is always first and always shown, like in the context menu. +- isTile ? null : (0, import_react18.createElement)( + isTile ? null : (0, import_react28.createElement)( -+ Card, -+ { title: t("Sidebar") }, + Card, + { title: t("Sidebar") }, +- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), +- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) + (0, import_react28.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), + isMainBot ? null : (0, import_react28.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react28.createElement)( -+ Card, + Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( + { title: t("More") }, + (0, import_react28.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, + "button", + { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), + (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ ), + ), +- isMainBot ? null : (0, import_react18.createElement)( + isMainBot ? null : (0, import_react28.createElement)( -+ "button", -+ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + "button", + { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react18.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), +- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) + (0, import_react28.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) -+ ), + ), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ ) -+ ) -+ ); -+} -+var BOT_SETTINGS_CSS = ` -+.bt-bs{display:flex;flex-direction:column;gap:18px} -+.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px} -+.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px} -+.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-bs .bt-avedit{left:0;transform:none} -+.bt-bs-row{min-height:48px} -+label.bt-bs-row{cursor:text} -+.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} + ) + ) + ); +@@ -8130,9 +13209,17 @@ + .bt-bs .bt-avedit{left:0;transform:none} + .bt-bs-row{min-height:48px} + label.bt-bs-row{cursor:text} +-.bt-bs-row>.bt-set-copy{flex:1 1 auto} + .bt-bs-label{font-size:13px;line-height:18px;font-weight:500} +/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to + break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test + connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under @@ -13928,29 +12038,16 @@ diff --git a/client.js b/client.js +.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch} +.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap} -+.bt-bs-row .bt-model-trigger{flex:none;max-width:58%} + .bt-bs-row .bt-model-trigger{flex:none;max-width:58%} +.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0} +${MODEL_TEST_CSS} -+.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} -+.bt-bs-input::placeholder{color:var(--bt-ink-3)} -+.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px} -+.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)} -+.bt-bs-area:hover{background:var(--bt-active)} -+.bt-bs-danger .bt-bs-label{color:#c21d2e} -+.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)} -+.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)} -+.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease} -+.bt-switch[aria-checked=true]{background:var(--bt-accent)} -+.bt-switch:disabled{opacity:.45;cursor:default} -+.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px} -+.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}} -+.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)} -+.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px} -+.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}} + .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,7 +13240,658 @@ + .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}} +/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below + 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and + keeps its own words. Nothing is clipped to fit a width that is not there. */ @@ -13959,7 +12056,7 @@ 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"); @@ -14204,9 +12301,7 @@ diff --git a/client.js b/client.js + (0, import_react29.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), + (0, import_react29.createElement)("span", { className: "bt-tag" }, t("Human")), + person.role === "admin" ? (0, import_react29.createElement)("span", { className: "bt-tag" }, t("Admin")) : null - ), -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ++ ), + humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react29.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null + )), + members.map((member) => { @@ -14286,80 +12381,31 @@ diff --git a/client.js b/client.js + // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after + // the note on who leads, so that note stays with the members it is about (#892). + room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react29.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }) - ), -- // The Main Bot tile is always first and always shown, like in the context menu. -- isTile ? null : (0, import_react18.createElement)( ++ ), + (0, import_react29.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react29.createElement)( - Card, - { title: t("Sidebar") }, -- (0, import_react18.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), -- isMainBot ? null : (0, import_react18.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) ++ Card, ++ { title: t("Sidebar") }, + (0, import_react29.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), + (0, import_react29.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react29.createElement)( - Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) -- ), -- isMainBot ? null : (0, import_react18.createElement)( ++ Card, + { title: t("More") }, + (0, import_react29.createElement)( - "button", - { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react18.createElement)( ++ "button", ++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + (0, import_react29.createElement)( - "span", - { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) ++ "span", ++ { className: "bt-set-copy" }, + (0, import_react29.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), + (0, import_react29.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_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ ), + (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon)) - ) - ) - ); - } --var BOT_SETTINGS_CSS = ` --.bt-bs{display:flex;flex-direction:column;gap:18px} --.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px} --.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px} --.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-bs .bt-avedit{left:0;transform:none} --.bt-bs-row{min-height:48px} --label.bt-bs-row{cursor:text} --.bt-bs-row>.bt-set-copy{flex:1 1 auto} --.bt-bs-label{font-size:13px;line-height:18px;font-weight:500} --.bt-bs-row .bt-model-trigger{flex:none;max-width:58%} --.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} --.bt-bs-input::placeholder{color:var(--bt-ink-3)} --.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px} --.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)} --.bt-bs-area:hover{background:var(--bt-active)} --.bt-bs-danger .bt-bs-label{color:#c21d2e} --.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)} --.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)} --.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease} --.bt-switch[aria-checked=true]{background:var(--bt-accent)} --.bt-switch:disabled{opacity:.45;cursor:default} --.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px} --.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}} --.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)} --.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px} --.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}} ++ ) ++ ) ++ ); ++} +function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { + const [admin, setAdmin] = (0, import_react29.useState)(null); + const [members, setMembers] = (0, import_react29.useState)([]); @@ -14454,8 +12500,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"); @@ -14654,11 +12700,10 @@ 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 +13903,48 @@ } function startModel(roster) { const models = roster.models ?? []; @@ -14722,7 +12767,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 +13956,8 @@ setError(""); setBusy(true); try { @@ -14733,7 +12778,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 +13969,25 @@ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; @@ -14767,7 +12812,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 +13999,27 @@ setLook((current) => ({ ...current, color })); } }), @@ -14803,7 +12848,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,73 +14034,111 @@ } } }), @@ -14914,8 +12959,8 @@ diff --git a/client.js b/client.js + setFix(ungrantedLocalFix(failure?.details) ?? null); } finally { setBusy(false); - } - }; ++ } ++ }; + const leaveOut = (id) => { + if (id === admin) setAdmin(null); + setPicked((current) => current.filter((other) => other !== id)); @@ -14942,12 +12987,10 @@ diff --git a/client.js b/client.js + } else { + setInviteLink(link); + setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name })); -+ } -+ }; + } + }; const pick2 = (bot) => { - setPicked((current) => [...current, bot.id]); - setQuery(""); -@@ -8386,71 +8386,104 @@ function NewChat({ mode, roster, actions }) { +@@ -8386,71 +14174,104 @@ }; const created = (bot) => { close(); @@ -15072,7 +13115,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 +14288,41 @@ if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; @@ -15129,7 +13172,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 +14346,20 @@ } else if (event.key === "Backspace" && query === "") unpick(); } }), @@ -15155,7 +13198,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +14371,36 @@ onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -15201,7 +13244,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 +14413,7 @@ } } }), @@ -15210,7 +13253,7 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +14421,41 @@ } // src/client/exchange.js @@ -15256,7 +13299,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 +14479,42 @@ const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -15320,7 +13363,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 +14525,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) { @@ -15338,7 +13381,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 +14576,18 @@ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); @@ -15368,7 +13411,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 +14595,24 @@ actions.closeVoice(); }; useEscape(close); @@ -15401,7 +13444,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 +14627,16 @@ }; }, [speech]); const listen = phase === "listening"; @@ -15421,7 +13464,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 +14649,9 @@ setPhase("transcribing"); try { const wave = await current.finish(catalog.maxDurationSeconds); @@ -15433,7 +13476,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 +14698,12 @@ setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); @@ -15450,7 +13493,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 +14718,7 @@ window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); @@ -15459,7 +13502,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,641 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } +@@ -8875,291 +14735,641 @@ const openSetup = phase === "setup" ? actions.voiceSetup() : null; const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; @@ -15540,7 +13583,24 @@ diff --git a/client.js b/client.js + const code = failure && typeof failure === "object" ? String(failure.code ?? "") : ""; + const message = typeof failure === "string" ? failure : String(failure?.message ?? ""); + return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message); -+} + } +-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +- const [adding, setAdding] = (0, import_react23.useState)(false); +- const [confirming, setConfirming] = (0, import_react23.useState)(false); +- (0, import_react23.useEffect)(() => { +- if (!confirming) return void 0; +- const timer = setTimeout(() => setConfirming(false), 4e3); +- return () => clearTimeout(timer); +- }, [confirming]); +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); +- const admin = roster.byId[room2.admin]; +- const mode = shownMode(room2, roster); +- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; +- const update = (patch) => run(actions.updateRoom(room2.id, patch)); +- const openBot = (id) => { +- actions.closeSettings(); +- actions.openSession(id); +function workflowCallError(failure, fallback = "Could not read workflows") { + if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT; + if (typeof failure === "string" && failure.trim()) return failure.trim(); @@ -15571,24 +13631,7 @@ diff --git a/client.js b/client.js + const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs); + Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null })); + }); - } --function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { -- const [adding, setAdding] = (0, import_react23.useState)(false); -- const [confirming, setConfirming] = (0, import_react23.useState)(false); -- (0, import_react23.useEffect)(() => { -- if (!confirming) return void 0; -- const timer = setTimeout(() => setConfirming(false), 4e3); -- return () => clearTimeout(timer); -- }, [confirming]); -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const outside = roster.bots.filter((bot) => !room2.members.includes(bot.id)); -- const admin = roster.byId[room2.admin]; -- const mode = shownMode(room2, roster); -- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ""; -- const update = (patch) => run(actions.updateRoom(room2.id, patch)); -- const openBot = (id) => { -- actions.closeSettings(); -- actions.openSession(id); ++} +function moveNode(nodes, id, x, y) { + return nodes.map((node) => node.id === id ? { ...node, x, y } : node); +} @@ -15711,49 +13754,6 @@ diff --git a/client.js b/client.js - { className: "bt-bs-hero" }, - (0, import_react23.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - (0, import_react23.createElement)( -- "div", -- { className: "bt-bs-who" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), -- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Profile") }, -- (0, import_react23.createElement)( -- Row, -- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, -- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) -- ), -- (0, import_react23.createElement)( -- 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_react23.createElement)( -- Card, -- { title: t("Who answers") }, -- (0, import_react23.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, -- type: "button", -- "aria-pressed": mode === id, -- disabled: id === "admin" && !admin, -- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, -- onClick: () => { -- if (mode !== id) void update({ mode: id }); -- } -- }, t(label)))), -- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Working folder") }, -- (0, import_react23.createElement)( + { className: "bt-wf-palette" }, + (0, import_react35.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")), + locals.map((bot) => (0, import_react35.createElement)("button", { @@ -15790,27 +13790,12 @@ diff --git a/client.js b/client.js + { className: "bt-wf-stage", style: { height: drawnHeight } }, + (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 }) }) -- ) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Members · {count}", { count: members.length }) }, -- members.map((member) => { -- const leads = room2.admin === member.id; -- return (0, import_react23.createElement)( +- { className: "bt-bs-who" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-name" }, room2.name), +- (0, import_react23.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) + { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } }, + (0, import_react35.createElement)( - "div", -- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, -- (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, -- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), -- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), -- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null ++ "div", + { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } }, + (0, import_react35.createElement)( + "svg", @@ -15831,45 +13816,16 @@ diff --git a/client.js b/client.js + refY: "5", + markerWidth: "6", + markerHeight: "6", -+ orient: "auto" -+ }, (0, import_react35.createElement)("path", { d: "M0 0 10 5 0 10z" })) -+ ), -+ edges.map((edge) => { -+ const from = nodes.find((node) => node.id === edge.from); -+ const to = nodes.find((node) => node.id === edge.to); -+ if (!from || !to) return null; -+ return (0, import_react35.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" }); -+ }) - ), -- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), -- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null -- ); -- }), -- adding ? [ -- ...outside.map((bot) => (0, import_react23.createElement)( -- "button", -- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { -- setAdding(false); -- void update({ add: [bot.id] }); -- } }, -- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), -- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), -- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) -- )), -- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, -- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( -- "span", -- { className: "bt-set-hint" }, -- // The host checks for two members on every change, even a rename. -- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") -- )) -- ), -- (0, import_react23.createElement)( -- Card, -- { title: t("Sidebar") }, -- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), -- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) ++ orient: "auto" ++ }, (0, import_react35.createElement)("path", { d: "M0 0 10 5 0 10z" })) ++ ), ++ edges.map((edge) => { ++ const from = nodes.find((node) => node.id === edge.from); ++ const to = nodes.find((node) => node.id === edge.to); ++ if (!from || !to) return null; ++ return (0, import_react35.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" }); ++ }) ++ ), + (graph.layers ?? []).filter((layer) => layer.length > 1).map((layer) => { + const members = layer.map((id) => nodes.find((node) => node.id === id)).filter(Boolean); + if (members.length < 2) return null; @@ -15930,7 +13886,7 @@ diff --git a/client.js b/client.js + ); + }) + ) -+ ) + ) + ) + ); +} @@ -15952,17 +13908,11 @@ diff --git a/client.js b/client.js ), - (0, import_react23.createElement)( - Card, -- { title: t("Manage") }, +- { title: t("Profile") }, - (0, import_react23.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react23.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), -- (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)) +- Row, +- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, +- (0, import_react23.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) + source === "own" ? (0, import_react35.createElement)( + "span", + { className: "bt-set-actions" }, @@ -16029,14 +13979,106 @@ diff --git a/client.js b/client.js + return (0, import_react35.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") }); + }) + ) -+ ), + ), +- (0, import_react23.createElement)( +- 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_react23.createElement)( +- Card, +- { title: t("Who answers") }, +- (0, import_react23.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, +- type: "button", +- "aria-pressed": mode === id, +- disabled: id === "admin" && !admin, +- title: id === "admin" && !admin ? t("Choose an admin first") : void 0, +- onClick: () => { +- if (mode !== id) void update({ mode: id }); +- } +- }, t(label)))), +- (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Working folder") }, +- (0, import_react23.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 }) }) +- ) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Members · {count}", { count: members.length }) }, +- 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", +- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, +- (0, import_react23.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), +- (0, import_react23.createElement)("span", { className: "bt-row-title" }, member.name), +- leads ? (0, import_react23.createElement)("span", { className: "bt-tag" }, t("Admin")) : null +- ), +- leads ? (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react23.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), +- members.length > 2 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null +- ); +- }), +- adding ? [ +- ...outside.map((bot) => (0, import_react23.createElement)( +- "button", +- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { +- setAdding(false); +- void update({ add: [bot.id] }); +- } }, +- (0, import_react23.createElement)(BotMark, { bot, size: 22 }), +- (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, bot.name)), +- (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Add")) +- )), +- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) +- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null, +- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( +- "span", +- { className: "bt-set-hint" }, +- // The host checks for two members on every change, even a rename. +- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.") +- )) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react23.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), +- (0, import_react23.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) +- ), +- (0, import_react23.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react23.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react23.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), +- (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 })) + ) @@ -16337,7 +14379,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 +15487,13 @@ } return result; } @@ -16355,7 +14397,7 @@ diff --git a/client.js b/client.js } return String(Math.round(count)); } -@@ -9294,11 +9294,11 @@ function percent(part, whole) { +@@ -9294,11 +15504,11 @@ } // src/client/usage-page.js @@ -16369,7 +14411,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 +15551,9 @@ return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { @@ -16382,7 +14424,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 +15573,9 @@ }, [value]); return shown; } @@ -16394,7 +14436,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 +15592,7 @@ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], @@ -16403,7 +14445,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "room") { -@@ -9392,7 +9392,7 @@ function ownerFace(owner, roster) { +@@ -9392,7 +15602,7 @@ name: owner.name || t("Deleted group chat"), sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", @@ -16412,7 +14454,7 @@ diff --git a/client.js b/client.js }; } if (owner.kind === "agent") { -@@ -9411,33 +9411,33 @@ function kindLabel(kind, botFocus) { +@@ -9411,33 +15621,33 @@ return labels[kind] ?? kind; } function Tabs({ label, value, options, onChange, boxRef }) { @@ -16457,7 +14499,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 +15665,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 }) { @@ -16569,7 +14611,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 +15750,69 @@ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, @@ -16667,7 +14709,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 +15838,17 @@ const [from, to] = [anchor, key].sort(); onPick(from, to); }; @@ -16688,7 +14730,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 +15864,15 @@ "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) @@ -16710,7 +14752,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 +15881,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; @@ -16749,7 +14791,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 +15920,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()) { @@ -16765,7 +14807,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 +15934,7 @@ "data-future": ms > today ? "" : void 0, "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) @@ -16774,7 +14816,7 @@ diff --git a/client.js b/client.js } } const tipSide = hover ? { -@@ -9733,61 +9733,61 @@ function Heat({ view, keep, paint, index }) { +@@ -9733,61 +15943,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 }); @@ -16862,7 +14904,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 +16007,7 @@ const value = await actions.usage(); await resized; if (stopped) return; @@ -16871,7 +14913,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 +16024,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); @@ -16880,7 +14922,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 +16033,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)"; @@ -16889,7 +14931,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 +16081,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]); @@ -16921,7 +14963,7 @@ diff --git a/client.js b/client.js ) ); } -@@ -9912,7 +9912,7 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9912,7 +16122,7 @@ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon] ]; const whole = part.total.total; @@ -16930,7 +14972,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 +16134,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 }) }); @@ -16939,7 +14981,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 +16146,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 }) }); @@ -16980,7 +15022,7 @@ diff --git a/client.js b/client.js ) ]; } else { -@@ -9968,39 +9968,39 @@ function UsagePage({ roster, actions, focusId }) { +@@ -9968,39 +16178,39 @@ const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ @@ -17034,7 +15076,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 +16360,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)}} @@ -17262,7 +15304,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 +16577,195 @@ after?.(); }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); }; @@ -17373,21 +15415,23 @@ diff --git a/client.js b/client.js + on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form" }, + "div", + { className: "bt-connector-form" }, +- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), +- (0, import_react25.createElement)( + (0, import_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), + (0, import_react37.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, + "div", + { className: "bt-connector-form-row" }, +- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), +- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react37.createElement)("span", { className: "bt-connector-link" }), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + ) + ) : null, + !on && form ? (0, import_react37.createElement)( - "div", - { className: "bt-connector-form" }, -- (0, import_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), -- (0, import_react25.createElement)( ++ "div", ++ { className: "bt-connector-form" }, + channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { + key: entry.id, + type: "button", @@ -17409,10 +15453,8 @@ diff --git a/client.js b/client.js + })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, + (0, import_react37.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), -- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ "div", ++ { className: "bt-connector-form-row" }, + (0, import_react37.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, @@ -17422,11 +15464,13 @@ diff --git a/client.js b/client.js setForm(false); setToken(""); + setValues({}); -+ 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 || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) -+ ) : null, + ) + ) : null, +- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + on && gateway && picking ? (0, import_react37.createElement)( + "div", + { className: "bt-connector-form" }, @@ -17438,13 +15482,11 @@ 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")), -- (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 || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) - ) - ) : null, -- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ ) ++ ) : null, + error ? (0, import_react37.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } @@ -17488,7 +15530,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 +16775,18 @@ if (entry.source === "turn") return failureLabel(entry.code, entry.text); return entry.text; } @@ -17514,7 +15556,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 +16794,7 @@ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it.")); if (!copied) setPreview(true); })); @@ -17523,7 +15565,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 +16812,8 @@ if (!inline && !copied) setPreview(true); })); }; @@ -17534,7 +15576,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 +16822,59 @@ const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ @@ -17622,7 +15664,7 @@ diff --git a/client.js b/client.js ) ) ]; -@@ -10377,8 +10377,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { +@@ -10377,8 +16882,8 @@ var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); @@ -17633,7 +15675,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 +16894,16 @@ }; }, [toast?.token]); if (!toast) return null; @@ -17655,7 +15697,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +10416,56 @@ var FEEDBACK_CSS = ` +@@ -10416,47 +16922,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} @@ -17726,7 +15768,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 +16981,72 @@ live = false; }; }, []); @@ -17837,7 +15879,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 +17069,59 @@ `; // src/client/settings.js @@ -17915,7 +15957,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 +17131,86 @@ style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), @@ -18025,7 +16067,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 +17222,69 @@ ); } function BotsPage({ roster, actions, run }) { @@ -18125,7 +16167,7 @@ diff --git a/client.js b/client.js useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); -@@ -10779,104 +10779,113 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +17295,113 @@ }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -18280,7 +16322,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 +17409,22 @@ const isMainBot = isMainOf(roster, menu.id); const isTile = roster.mainBotId === menu.id; const mainCount = (roster.mainBotIds ?? []).length; @@ -18305,7 +16347,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 +17435,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 ], @@ -18334,7 +16376,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 +17459,14 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -18353,7 +16395,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 +17488,14 @@ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; @@ -18373,7 +16415,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,9 +17504,23 @@ actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } @@ -18381,8 +16423,8 @@ diff --git a/client.js b/client.js + }, (0, import_react41.createElement)(Icon), label)) )) ); - } - ++} ++ +// src/client/pane-width.js +var DEFAULT_WIDTH = 520; +var DETAILS_WIDTH = 380; @@ -18395,10 +16437,10 @@ diff --git a/client.js b/client.js + if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)]; + const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2)); + return [low, Math.max(low, Math.min(DETAILS_MAX, room2))]; -+} -+ + } + // src/client/storage.js - var STORAGE = { +@@ -10978,6 +17528,7 @@ unread: "ds-bot:unread", reactions: "ds-bot:reactions", seen: "ds-bot:seen", @@ -18406,42 +16448,10 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11006,276 +11006,40 @@ var migrateBrowsers = (storage) => { - const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" }; - return [botId, { tabs: [tab], active: tab.id }]; - })); -- storage.setItem(STORAGE.browsers, JSON.stringify(tabs)); -- for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier); -- return; -- } catch { -- } -- } --}; --function migrateStorage(storage = globalThis.localStorage) { -- for (const key of Object.values(STORAGE)) { -- if (key === STORAGE.browsers) continue; -- for (const legacy of legacyKeys(key)) { -- try { -- const value = storage.getItem(legacy); -- if (value !== null && storage.getItem(key) === null) storage.setItem(key, value); -- storage.removeItem(legacy); -- } catch { -- } -- } -- } -- try { -- migrateBrowsers(storage); -- } catch { -- } -- try { -- const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null"); -- if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) { -- storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); -- } -- } catch { -- } --} -- +@@ -11038,244 +17589,8 @@ + } + } + -// src/client/member-status.js -var import_react30 = require("react"); -var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -18667,7 +16677,7 @@ diff --git a/client.js b/client.js -@media (prefers-reduced-motion:reduce){.bt-mw-dock>button,.bt-mw-row{animation:none}} -`; - --// src/client/details-drawer.js + // src/client/details-drawer.js -var import_react31 = require("react"); -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; @@ -18677,45 +16687,14 @@ diff --git a/client.js b/client.js - if (hiddenTurns.includes(entry.turn)) continue; - if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); - if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`); -+ storage.setItem(STORAGE.browsers, JSON.stringify(tabs)); -+ for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier); -+ return; -+ } catch { -+ } -+ } -+}; -+function migrateStorage(storage = globalThis.localStorage) { -+ for (const key of Object.values(STORAGE)) { -+ if (key === STORAGE.browsers) continue; -+ for (const legacy of legacyKeys(key)) { -+ try { -+ const value = storage.getItem(legacy); -+ if (value !== null && storage.getItem(key) === null) storage.setItem(key, value); -+ storage.removeItem(legacy); -+ } catch { -+ } -+ } -+ } -+ try { -+ migrateBrowsers(storage); -+ } catch { -+ } -+ try { -+ const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null"); -+ if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) { -+ storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); -+ } -+ } catch { - } +- } - return lines.join("\n\n"); - } -+ -+// src/client/details-drawer.js +-} +var import_react42 = require("react"); var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { -@@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; +@@ -11289,10 +17604,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 }) { @@ -18728,7 +16707,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 +17617,14 @@ setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; @@ -18747,7 +16726,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 +17638,42 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); @@ -18805,7 +16784,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 +17682,195 @@ void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -18837,8 +16816,11 @@ diff --git a/client.js b/client.js useMorph(body, open); - return (0, import_react31.createElement)( + 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)("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 @@ -18891,11 +16873,8 @@ 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, -- (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)(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, { @@ -19046,7 +17025,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 +17880,7 @@ const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); @@ -19055,7 +17034,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 +17890,67 @@ return false; }); const share = () => { @@ -19139,7 +17118,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 +17964,12 @@ const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ @@ -19156,7 +17135,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 +17984,97 @@ setEditing(false); } } @@ -19300,7 +17279,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 +18122,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); @@ -19317,7 +17296,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 +18155,7 @@ trail.cancel(); }; }); @@ -19326,7 +17305,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 +18169,13 @@ observer.observe(box); return () => observer.disconnect(); }, []); @@ -19343,7 +17322,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 +18185,13 @@ title: bot.name, onClick: () => onView("details") }, @@ -19360,7 +17339,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 +18212,9 @@ } } }, @@ -19373,7 +17352,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 +18226,20 @@ }, "×") ); }), @@ -19401,7 +17380,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 +18265,43 @@ window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); @@ -19458,7 +17437,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 +18325,25 @@ } }) ), @@ -19494,7 +17473,7 @@ diff --git a/client.js b/client.js ) : null ) ); -@@ -11907,41 +11907,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu +@@ -11907,41 +18355,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); @@ -19552,7 +17531,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 +18403,28 @@ window.removeEventListener("resize", onResize); }; }, []); @@ -19588,7 +17567,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 +18461,7 @@ return actions[name](...args); }; } @@ -19597,7 +17576,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 +18544,7 @@ for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; @@ -19606,7 +17585,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 +18561,15 @@ if (event.target === event.currentTarget) setLanded(true); } }, @@ -19627,7 +17606,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 +18665,79 @@ @media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}} `; @@ -19709,7 +17688,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 +18745,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); @@ -19729,7 +17708,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 +18763,26 @@ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]); const pane = open ?? kept; const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS); @@ -19766,7 +17745,7 @@ diff --git a/client.js b/client.js ); } -@@ -12294,19 +12294,19 @@ function readPage(limits) { +@@ -12294,19 +18819,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 || ""}`); }; @@ -19793,7 +17772,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 +18865,8 @@ items.push(item); return { item, number: items.length, secret }; }; @@ -19804,7 +17783,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 +18897,7 @@ const marker = ordered ? `${number++}. ` : "- "; if (body2 === "") continue; const pad2 = " ".repeat(marker.length); @@ -19813,7 +17792,7 @@ diff --git a/client.js b/client.js } return keep(lines.join("\n")); }; -@@ -12398,11 +12398,11 @@ ${children(cell, ctx)} +@@ -12398,11 +18923,11 @@ let width = 0; for (const row of rows.slice(0, limits.tableRows + 1)) { if (over()) break; @@ -19827,7 +17806,7 @@ diff --git a/client.js b/client.js carry[column].left -= 1; column += 1; } -@@ -12410,18 +12410,18 @@ ${children(cell, ctx)} +@@ -12410,18 +18935,18 @@ for (const cell of row.cells) { place(); if (styleOf(cell).display === "none") continue; @@ -19851,7 +17830,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 +19005,9 @@ if (node.nodeType === 3) { if (!ctx.shown) return ""; const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, ""); @@ -19864,7 +17843,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 +19077,7 @@ } if (tag === "blockquote") { const quoted = block(body2); @@ -19873,7 +17852,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 +19095,12 @@ return marked; } const body = document.body; @@ -19890,7 +17869,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 +19110,7 @@ description: document.querySelector('meta[name="description"]')?.content || "", selection: String(getSelection() || "").slice(0, 4e3), format: "markdown", @@ -19899,7 +17878,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 +19138,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) { @@ -19923,7 +17902,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,316 @@ function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.random +@@ -13052,11 +19577,421 @@ }; } @@ -20036,6 +18015,18 @@ diff --git a/client.js b/client.js +.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} +.bt-project-empty{padding:22px 4px;opacity:.7} ++/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new ++ component language: a heading, a list, and a monospace hash the reader can compare. */ ++.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} ++.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} ++.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} ++.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} ++.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} ++.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} ++.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} ++.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} ++.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} +`; +var PROJECT_SOURCES = ["bot", "group", "workflow"]; @@ -20053,7 +18044,59 @@ diff --git a/client.js b/client.js + } + return null; +}; -+function projectBoardView({ project, rows, canManage, tasks } = {}) { ++var decisionOf = (file) => { ++ if (file === null || typeof file !== "object") return null; ++ const path = text(file.path); ++ if (path === "") return null; ++ return { ++ key: `decision:${path}`, ++ path, ++ name: text(file.name) || path.slice(path.lastIndexOf("/") + 1), ++ date: text(file.date), ++ title: text(file.title), ++ sha256: text(file.sha256), ++ shortHash: text(file.sha256).slice(0, 12) ++ }; ++}; ++var meetingOf = (meeting, status) => { ++ if (meeting === null || typeof meeting !== "object") return null; ++ const id = text(meeting.id); ++ if (id === "") return null; ++ return { ++ key: `meeting:${id}`, ++ id, ++ date: text(meeting.date), ++ question: text(meeting.question) || text(meeting.title), ++ owner: text(meeting.owner) || null, ++ status, ++ path: text(meeting.path), ++ blockingQuestion: text(meeting.blockingQuestion) || null ++ }; ++}; ++var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : []; ++function projectBoardRecord({ decisions, meetings } = {}) { ++ const dir = text(decisions?.dir) || null; ++ const files = listOf(decisions?.files); ++ const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : []; ++ const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : []; ++ return { ++ decisions: { ++ dir, ++ files, ++ count: files.length, ++ empty: files.length === 0, ++ emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) ++ }, ++ meetings: { ++ decided, ++ pending, ++ count: decided.length + pending.length, ++ empty: decided.length === 0 && pending.length === 0, ++ emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." ++ } ++ }; ++} ++function projectBoardView({ project, rows, canManage, tasks, decisions, meetings } = {}) { + 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) => ({ @@ -20082,7 +18125,8 @@ diff --git a/client.js b/client.js + groups, + count: list.length, + empty: list.length === 0, -+ emptyText: "Nothing under this Project yet." ++ emptyText: "Nothing under this Project yet.", ++ record: projectBoardRecord({ decisions, meetings }) + }; +} +function ProjectBoardRows({ view, onOpen }) { @@ -20123,15 +18167,53 @@ diff --git a/client.js b/client.js + }) + )); +} -+function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { -+ const view = projectBoardView({ project, rows, canManage, tasks }); ++function ProjectBoardRecord({ record }) { ++ const { decisions, meetings } = record; ++ const decisionRows = decisions.files.map((file) => (0, import_react46.createElement)( ++ "div", ++ { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 }, ++ (0, import_react46.createElement)("span", { className: "bt-project-date" }, file.date), ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title), ++ (0, import_react46.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash) ++ )); ++ const decidedRows = meetings.decided.map((meeting) => (0, import_react46.createElement)( ++ "div", ++ { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" }, ++ (0, import_react46.createElement)("span", { className: "bt-project-date" }, meeting.date), ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, meeting.question), ++ meeting.owner === null ? null : (0, import_react46.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`), ++ // A decided meeting whose file is gone still reads as decided; the missing path says so ++ // instead of the row disappearing with the file. ++ meeting.path === "" ? (0, import_react46.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react46.createElement)("span", { className: "bt-project-path" }, meeting.path) ++ )); ++ const pendingRows = meetings.pending.map((meeting) => (0, import_react46.createElement)( ++ "div", ++ { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status }, ++ (0, import_react46.createElement)("span", { className: "bt-project-date" }, meeting.date), ++ (0, import_react46.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), ++ (0, import_react46.createElement)("span", { className: "bt-project-status" }, meeting.status) ++ )); ++ return (0, import_react46.createElement)( ++ "section", ++ { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) }, ++ (0, import_react46.createElement)("h4", null, t("Decisions")), ++ decisions.empty ? (0, import_react46.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows, ++ (0, import_react46.createElement)("h4", null, t("Meetings")), ++ meetings.empty ? (0, import_react46.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows, ++ meetings.pending.length === 0 ? null : (0, import_react46.createElement)("h5", null, t("Not decided")), ++ meetings.pending.length === 0 ? null : pendingRows ++ ); ++} ++function ProjectBoardPane({ project, rows, canManage, tasks, decisions, meetings, onOpen }) { ++ const view = projectBoardView({ project, rows, canManage, tasks, decisions, meetings }); + 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), + // 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)(ProjectBoardRows, { view, onOpen }), ++ (0, import_react46.createElement)(ProjectBoardRecord, { record: view.record }) + ); +} +function ProjectBoardOverlay({ actions } = {}) { @@ -20171,7 +18253,7 @@ diff --git a/client.js b/client.js + call("tasks", {}) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; -+ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }); ++ if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }); + } catch { + if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }); + } @@ -20203,6 +18285,8 @@ diff --git a/client.js b/client.js + rows: state.rows, + canManage: state.canManage, + tasks: state.tasks, ++ decisions: state.decisions, ++ meetings: state.meetings, + // 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) => { @@ -20241,7 +18325,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 +20010,8 @@ }; return { reconcile(wanted) { @@ -20252,7 +18336,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 +20021,7 @@ } }, dispose() { @@ -20261,7 +18345,7 @@ diff --git a/client.js b/client.js } }; } -@@ -13105,7 +13105,7 @@ function writeMode(mode, storage = globalThis.localStorage) { +@@ -13105,7 +20040,7 @@ } // src/client/user-message.js @@ -20270,7 +18354,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 +20054,165 @@ } return { text: texts2.join("\n"), attachments, rest }; } @@ -20306,7 +18390,8 @@ diff --git a/client.js b/client.js + const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); + const remotes = (room2?.participants ?? []).filter((p) => p.remote && p.leftSeq === void 0).map((p) => withMentionLook({ id: p.handle || p.id, name: remoteBotTitle(p), kind: "bot", remote: p, mentionLabel: remoteBotTitle(p) }, roster)); + return bots.concat(humans, remotes, remoteMentionableBots(roster, roster.bots)); -+} + } +-function userTextParts(text, bots, referenceLabels = []) { +function faceMentionables(room2, roster, fromFriend) { + const humans = (room2.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0).map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })); + const all = faceSeats(room2, roster, { left: true }); @@ -20334,8 +18419,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; @@ -20470,7 +18554,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 +20229,115 @@ `; // src/client/approval-notices.js @@ -20552,7 +18636,7 @@ diff --git a/client.js b/client.js + off(); + timers.clearTimeout?.(timer); + }; -+} + } + +// src/client/stats-dock.js +var import_react48 = require("react"); @@ -20567,7 +18651,7 @@ diff --git a/client.js b/client.js + const { turns, steps } = params; + return text2 === `${turns} turns ${steps} steps` ? `${noun(turns, "turn", "turns")} ${noun(steps, "step", "steps")}` : text2; + }; - } ++} +function statsDock(entriesOf, id = STATS_ID) { + const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); + function StatsDock(props) { @@ -20599,7 +18683,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 +20360,13 @@ } catch { seenAt = {}; } @@ -20614,7 +18698,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 +20400,8 @@ return () => window.removeEventListener("pagehide", onPageHide); }, "ds-bot: remember visits on page hide"); const activity = createSource({}); @@ -20623,7 +18707,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 +20438,25 @@ } return { ...state, unread }; }); @@ -20650,7 +18734,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 +20464,11 @@ activityKey = nextActivity; activity.set(value.activity ?? {}); } @@ -20662,7 +18746,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 +20479,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; @@ -20678,7 +18762,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 +20604,12 @@ const style = installStyles("", "mentions"); let last = ""; const render = () => { @@ -20701,7 +18785,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13520,7 +13520,7 @@ function apply(ctx) { +@@ -13520,7 +20628,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; @@ -20710,7 +18794,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 +20644,7 @@ }; render(); const offRoster = roster.subscribe(render); @@ -20719,7 +18803,7 @@ diff --git a/client.js b/client.js return () => { offRoster(); offRegistry(); -@@ -13588,6 +13588,7 @@ function apply(ctx) { +@@ -13588,6 +20696,7 @@ // or the click appears to do nothing. isAgentSession: (id) => isAgentSessionId(id), openSession: (id) => { @@ -20727,7 +18811,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 +20762,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. @@ -20743,7 +18827,7 @@ diff --git a/client.js b/client.js expected = target; ctx.uiWorkspace.openSession(target); }, -@@ -13708,10 +13708,15 @@ function apply(ctx) { +@@ -13708,10 +20817,15 @@ }, // `id` opens one Bot (page 'bots') or one group chat (page 'groups'). openSettings: (page = "general", id = null) => { @@ -20761,7 +18845,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 +20833,19 @@ closeMemory: () => { ui2.set((state) => state.memory ? { ...state, memory: null } : state); }, @@ -20781,7 +18865,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 +20855,7 @@ if (mode !== "bot" && mode !== "agent") return; if (surface.getSnapshot().mode === mode) return; if (mode === "agent") { @@ -20790,7 +18874,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 +20884,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() } } })); }, @@ -20803,7 +18887,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 +20913,7 @@ } return { ...state, reactions }; }), @@ -20812,7 +18896,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 +20941,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 })), @@ -20830,7 +18914,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 +20958,31 @@ secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -20863,7 +18947,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 +20991,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 }), @@ -20872,7 +18956,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 +21001,8 @@ noteClientError(where, error); ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } })); }, @@ -20881,7 +18965,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 +21047,7 @@ actions.closePanel = () => { ui2.set((state) => state.details ? { ...state, details: null, view: "details", renaming: false, tab: null } : state); }; @@ -20889,7 +18973,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 +21080,9 @@ wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); @@ -20899,7 +18983,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 +21125,13 @@ }); const browserPages = browsers?.pages ?? createSource({}); const browserTabs = browsers?.tabs ?? createSource({}); @@ -20914,7 +18998,7 @@ diff --git a/client.js b/client.js ctx.effect(() => noticeApprovals({ work, roster, -@@ -13989,7 +13989,17 @@ function apply(ctx) { +@@ -13989,7 +21156,17 @@ } }; }; const shadow = (name, options, component) => ctx.slots.inject(name, () => ctx.slots.register({ name, ...options }, component)); @@ -20932,7 +19016,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 +21178,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 }; @@ -20945,7 +19029,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 +21192,14 @@ return () => fiber.dispose(); }; actions.canReferenceChat = () => atMenu !== null; @@ -20960,7 +19044,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 +21209,49 @@ installCompiledStyles(SHELL_CSS, "shell"), installCompiledStyles(SIDEBAR_CSS, "sidebar"), installCompiledStyles(SECRET_CSS, "secrets"), @@ -21014,7 +19098,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 +21263,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), @@ -21042,7 +19126,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,8 +21289,17 @@ shadow("conversation.input.dock", { id: "bot-members", order: -120, inject: face }, MemberDock), shadow("conversation.input.dock", { id: "bot-model", order: -110, inject: face }, ModelChoiceDock), shadow("conversation.input.dock", { id: "bot-question", order: -100, inject: face }, QuestionDock), diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 3304c5702..5b33db460 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -21,6 +21,18 @@ export const PROJECT_BOARD_CSS = ` .bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)} .bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6} .bt-project-empty{padding:22px 4px;opacity:.7} +/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new + component language: a heading, a list, and a monospace hash the reader can compare. */ +.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0} +.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} +.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55} +.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px} +.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums} +.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)} +.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0} +.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)} +.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} ` @@ -47,10 +59,72 @@ const boardOfRow = (row, task) => { return null } +// One decision file as the board lists it: the name, the day in the name, the title line and the +// hash the host computed. Nothing here reads a body — the view receives what the read answered. +const decisionOf = (file) => { + if (file === null || typeof file !== 'object') return null + const path = text(file.path) + if (path === '') return null + return { + key: `decision:${path}`, + path, + name: text(file.name) || path.slice(path.lastIndexOf('/') + 1), + date: text(file.date), + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12), + } +} + +const meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== 'object') return null + const id = text(meeting.id) + if (id === '') return null + return { + key: `meeting:${id}`, + id, + date: text(meeting.date), + question: text(meeting.question) || text(meeting.title), + owner: text(meeting.owner) || null, + status, + path: text(meeting.path), + blockingQuestion: text(meeting.blockingQuestion) || null, + } +} + +const listOf = (value) => (Array.isArray(value) ? value.map(item => decisionOf(item)).filter(item => item !== null) : []) + +// The record beside the cards (#1767). A section with nothing in it still says which folder was +// read and why it holds no file, because an empty box reads as "there is nothing to decide", and +// a Project naming no folder is a different answer from a folder with no decision in it. +export function projectBoardRecord({ decisions, meetings } = {}) { + const dir = text(decisions?.dir) || null + const files = listOf(decisions?.files) + const decided = Array.isArray(meetings?.decided) ? meetings.decided.map(item => meetingOf(item, 'decided')).filter(item => item !== null) : [] + const pending = Array.isArray(meetings?.pending) ? meetings.pending.map(item => meetingOf(item, item?.status ?? 'deliberating')).filter(item => item !== null) : [] + return { + decisions: { + dir, + files, + count: files.length, + empty: files.length === 0, + emptyText: text(decisions?.reason) || (dir === null ? 'This Project names no working folder.' : `No decision file in ${dir}/decisions.`), + }, + meetings: { + decided, + pending, + count: decided.length + pending.length, + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || 'No meeting of this Project is recorded.', + }, + } +} + // Pure: no fetch, no React, no store. `rows` is the host's read model, `canManage` comes from // `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. -export function projectBoardView({ project, rows, canManage, tasks } = {}) { +// `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 } = {}) { 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) !== '') @@ -86,6 +160,7 @@ export function projectBoardView({ project, rows, canManage, tasks } = {}) { count: list.length, empty: list.length === 0, emptyText: 'Nothing under this Project yet.', + record: projectBoardRecord({ decisions, meetings }), } } @@ -118,13 +193,45 @@ function ProjectBoardRows({ view, onOpen }) { }))) } -export function ProjectBoardPane({ project, rows, canManage, tasks, onOpen }) { - const view = projectBoardView({ project, rows, canManage, tasks }) +// 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. +function ProjectBoardRecord({ record }) { + const { decisions, meetings } = record + const decisionRows = decisions.files.map(file => h('div', { key: file.key, className: 'bt-project-decision', 'data-path': file.path, 'data-sha256': file.sha256 }, + h('span', { className: 'bt-project-date' }, file.date), + h('span', { className: 'bt-project-title' }, file.title === '' ? file.name : file.title), + h('span', { className: 'bt-project-hash', title: file.sha256 }, file.shortHash))) + const decidedRows = meetings.decided.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': 'decided' }, + h('span', { className: 'bt-project-date' }, meeting.date), + h('span', { className: 'bt-project-title' }, meeting.question), + meeting.owner === null ? null : h('span', { className: 'bt-project-owner' }, `@${meeting.owner}`), + // A decided meeting whose file is gone still reads as decided; the missing path says so + // instead of the row disappearing with the file. + meeting.path === '' + ? h('span', { className: 'bt-project-missing' }, 'decision file gone') + : h('span', { className: 'bt-project-path' }, meeting.path))) + const pendingRows = meetings.pending.map(meeting => h('div', { key: meeting.key, className: 'bt-project-meeting', 'data-status': meeting.status }, + h('span', { className: 'bt-project-date' }, meeting.date), + h('span', { className: 'bt-project-title' }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion), + h('span', { className: 'bt-project-status' }, meeting.status))) + return h('section', { className: 'bt-project-record', 'data-decisions': String(decisions.count), 'data-meetings': String(meetings.count) }, + h('h4', null, t('Decisions')), + decisions.empty ? h('div', { className: 'bt-project-empty bt-project-record-empty' }, t(decisions.emptyText)) : decisionRows, + h('h4', null, t('Meetings')), + meetings.empty ? h('div', { className: 'bt-project-empty bt-project-record-empty' }, t(meetings.emptyText)) : decidedRows, + meetings.pending.length === 0 ? null : h('h5', null, t('Not decided')), + 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 }) return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' }, h('h3', null, view.title), // 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(ProjectBoardRows, { view, onOpen }), + h(ProjectBoardRecord, { record: view.record })) } // The pane entry point (#1710): `#project=` opens one Project's board, `#project` the first. @@ -164,7 +271,7 @@ export function ProjectBoardOverlay({ actions } = {}) { call('tasks', {}), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) - if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks }) + if (live) setState({ project: read?.project ?? project, rows: board?.tasks ?? [], canManage: read?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings }) } catch { if (live) setState({ project: null, rows: [], canManage: false, tasks: [] }) } })() return () => { live = false } @@ -186,6 +293,7 @@ export function ProjectBoardOverlay({ actions } = {}) { // 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, // 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-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index a532b0326..bffb6e0d4 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -438,7 +438,10 @@ export function install(rt) { const listed = text(ownerId) === '' ? ownersOf(project) : [text(ownerId)] const owners = listed.length === 0 ? [''] : listed const who = identity(project.id) - const rows = rt.projectBoard?.(project.id) ?? [] + // The board read is the same one the page uses (#1767): the digest wants its cards, not the + // decisions and meetings that answer beside them. + const board = (await rt.projectBoard?.(project.id)) ?? [] + const rows = Array.isArray(board) ? board : (board.rows ?? []) const state = project.workDir === undefined ? null : await listProjectState(project.workDir).catch(() => null) const files = state?.ok === true ? state.files : [] const results = [] diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index 808b687da..ee8c091d3 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -2,13 +2,59 @@ // run names the same id through `run.task.id` and never mints a second task, so a card a run // consumed is one row that carries the run, not two views of the same work. // -// Read-only by construction: it reads `state.tasks` and the Project bindings, asks each -// registered provider for the workflow refs of tasks that still exist, and returns rows. It -// writes nothing, starts nothing and is not called unless someone asks for the board. +// #1767 adds the other half of the record beside the cards: the `decisions/*.md` files of the +// Project's working folder and the meetings that reached `decided`. It is the same read — the +// 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. +// +// 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. +import { open } from 'node:fs/promises' +import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' +import { DECISIONS_DIR, listProjectState } from './project-state.js' const text = value => (typeof value === 'string' ? value.trim() : '') +// 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. +const TITLE_BYTES = 4096 +const TITLE_MAX = 200 +const DECISIONS_MAX = 200 +const MEETINGS_MAX = 100 + +const fileName = path => path.slice(path.lastIndexOf('/') + 1) +// The day a decision file records is the one in its name (`decisions/2026-10-30-x.md`), so two +// files of one day read as one day even when the folder was copied and their mtimes differ. +const dateOfName = (name) => { + const match = /^(\d{4}-\d{2}-\d{2})/.exec(name) + return match === null ? '' : match[1] +} + +async function titleOf(dir, path) { + let handle + try { + handle = await open(join(dir, path), 'r') + const buffer = Buffer.alloc(TITLE_BYTES) + const { bytesRead } = await handle.read(buffer, 0, TITLE_BYTES, 0) + for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) { + const line = raw.trim() + if (line === '') continue + return line.replace(/^#+\s*/, '').slice(0, TITLE_MAX) + } + return '' + } catch { + // A file that vanished between the listing and this read answers no title rather than a + // failed board: the row itself is the other read's. + return '' + } finally { + // Whichever way the read ended, the handle is closed and nothing is left to close. + if (handle !== undefined) await handle.close().catch(() => { /* nothing left to close */ }) + } +} + // The board a card belongs to. A group card names its room; a Bot card names its owner. A card // with neither (a run-only card) has no board and resolves through the run's own binding. const boardOf = (task) => { @@ -20,12 +66,122 @@ const boardOf = (task) => { } export function install(rt) { - // Rows for one Project, deduped by task id. An unknown Project and a Project with nothing - // under it both answer `[]`: reading a board is not a reason to throw. - const projectBoard = (projectId, actor) => { + // The folder a Project's decisions live in. A Project names one itself, and when it names none + // the folder is the one its members work in — the same folder a meeting resolves through + // `workDirFor` before it archives a decision, so the reader and the writer look in one place. + const folderOf = (project) => { + const own = text(project?.workDir) + if (own !== '') return own + for (const room of Object.values(rt.state?.rooms ?? {})) { + if (room === null || typeof room !== 'object') continue + const dir = text(room.workDir) + if (dir !== '' && rt.projectOfMember?.('group', room.id)?.id === project?.id) return dir + } + for (const bot of Object.values(rt.state?.bots ?? {})) { + if (bot === null || typeof bot !== 'object') continue + const dir = text(bot.workDir) + if (dir !== '' && rt.projectOfMember?.('bot', bot.id)?.id === project?.id) return dir + } + return '' + } + + const decisionsOf = async (project) => { + const dir = folderOf(project) + if (dir === '') return { dir: null, files: [], reason: 'no working folder: this Project names none' } + let state + try { + state = await listProjectState(dir) + } catch (error) { + return { dir, files: [], reason: `unreadable: ${error?.message ?? String(error)}` } + } + if (state.ok !== true) return { dir, files: [], reason: state.reason } + const found = state.files + .filter(file => file.path.startsWith(`${DECISIONS_DIR}/`) && file.path.endsWith('.md')) + .slice(0, DECISIONS_MAX) + const files = [] + for (const file of found) { + const name = fileName(file.path) + files.push({ + path: file.path, + name, + date: dateOfName(name), + title: await titleOf(dir, file.path), + bytes: file.bytes, + sha256: file.sha256, + mtime: file.mtime, + }) + } + return { dir, files, reason: files.length === 0 ? `no decision file in ${join(dir, DECISIONS_DIR)}` : '' } + } + + // A meeting names the Project it belongs to by id or by name (the tool's `project` argument is + // free text), so both forms resolve here rather than one being silently dropped. + const inProject = (meeting, project) => { + const named = text(meeting?.project).toLowerCase() + if (named === '') return false + return [text(project?.id), text(project?.name)] + .filter(value => value !== '') + .some(value => value.toLowerCase() === named) + } + + const meetingsOf = (project, decisions) => { + const known = new Map(decisions.files.map(file => [file.path, file])) + const mine = Object.values(rt.state?.meetings ?? {}) + .filter(meeting => meeting !== null && typeof meeting === 'object' && inProject(meeting, project)) + .sort((a, b) => (b.decidedAt ?? b.updatedAt ?? 0) - (a.decidedAt ?? a.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + const decided = [] + const pending = [] + for (const meeting of mine) { + const id = text(meeting.id) + const question = text(meeting.question ?? meeting.title) + if (meeting.status === 'decided') { + // A decided meeting is the decision's record, not the file's: the file may have been + // deleted or copied away, and the meeting still says what was decided. `path` is empty + // exactly then, and the reader can see the file the record names is gone. + const named = text(meeting.decisionFile) + const file = known.get(named) + decided.push({ + id, + date: text(meeting.decidedAt) !== '' ? new Date(meeting.decidedAt).toISOString().slice(0, 10) : '', + question, + owner: text(meeting.owner) || null, + path: file === undefined ? '' : file.path, + ...(file === undefined ? {} : { sha256: file.sha256 }), + }) + continue + } + // Only a decided meeting is a decision. A meeting still open, deliberating or blocked is + // listed beside those, with the state it is in, so nobody reads a question as an outcome. + if (meeting.status === 'open' || meeting.status === 'deliberating' || meeting.status === 'blocked') { + pending.push({ + id, + date: text(meeting.createdAt) !== '' ? new Date(meeting.createdAt).toISOString().slice(0, 10) : '', + question, + status: meeting.status, + ...(meeting.status === 'blocked' && text(meeting.blockingQuestion) !== '' ? { blockingQuestion: text(meeting.blockingQuestion) } : {}), + }) + } + } + return { + decided: decided.slice(0, MEETINGS_MAX), + pending: pending.slice(0, MEETINGS_MAX), + reason: mine.length === 0 ? 'no meeting of this Project is recorded' : '', + } + } + + // One board: the task rows, then the decisions and meetings that read beside them. An unknown + // Project and a Project with nothing under it both answer empty sections: reading a board is + // not a reason to throw. + const projectBoard = async (projectId, actor) => { const id = text(projectId) - if (id === '') return [] - if (!rt.readProject?.(id)) return [] + const empty = { + rows: [], + 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 ?? {} const refs = new Map() for (const ref of rt.projectRunRefs?.({ hasTask: taskId => Object.hasOwn(tasks, taskId) }) ?? []) { @@ -76,7 +232,8 @@ export function install(rt) { ...(fromRun ? { runId: ref.runId, ...(ref.nodeId ? { nodeId: ref.nodeId } : {}) } : {}), }) } - return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || String(a.id).localeCompare(String(b.id))) + const decisions = await decisionsOf(project) + return { rows, decisions, meetings: meetingsOf(project, decisions) } } rt.projectBoard = projectBoard } diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js index 5ba8cca6c..4f95aac49 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -522,7 +522,8 @@ test('a card lists for the owner of the Bot that owns it, through the real task } await rt.taskEndpoint('task-accept', { id: moving }, null) await rt.taskEndpoint('task-start', { id: moving }, null) - const rows = rt.projectBoard(project.id) + // #1767: the same call now answers the record beside the rows, so the rows are read off it. + const rows = (await rt.projectBoard(project.id)).rows assert.equal(rows.length, 3) assert.ok(rows.every(row => row.assignee === null), 'the board carries a Bot name or null, never a member id') assert.equal(actionableFor(rows, 'owner-1').length, 0, 'the raw helper sees no owner without a resolver') 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 f42af09b2..c05847f36 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -105,3 +105,64 @@ test('an empty Project renders the empty text and no row', () => { assert.equal(nodes.filter(node => node.props?.['data-task-id'] !== undefined).length, 0) assert.match(nodes.map(textOf).join(' '), /Nothing under this Project yet/) }) + +// #1767: the record beside the cards. The view passes through what the host read — a decision +// with its hash, the meetings that reached one, and the ones still open listed apart — and an +// empty section says which folder was read instead of drawing an empty box. +const decisions = { + dir: '/team/flybot', + files: [ + { path: 'decisions/2026-10-30-adopt.md', name: '2026-10-30-adopt.md', date: '2026-10-30', title: 'Adopt the board record', sha256: 'a'.repeat(64), mtime: '2026-10-30T01:00:00.000Z' }, + { path: 'decisions/2026-10-31-second.md', name: '2026-10-31-second.md', date: '2026-10-31', title: 'Keep decisions small', sha256: 'b'.repeat(64), mtime: '2026-10-31T01:00:00.000Z' }, + ], + reason: '', +} +const meetings = { + decided: [{ id: 'meeting-d', date: '2026-10-30', question: 'Which board carries the record?', owner: 'Chief', path: 'decisions/2026-10-30-adopt.md' }], + pending: [{ id: 'meeting-o', date: '2026-10-30', question: 'Should the board page?', status: 'deliberating' }], + reason: '', +} + +test('#1767: the view carries the decisions with their hash and the meetings apart', () => { + const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, decisions, meetings, canManage: false })) + assert.equal(view.record.decisions.count, 2) + assert.equal(view.record.decisions.empty, false) + assert.equal(view.record.decisions.files[0].shortHash, 'a'.repeat(12)) + assert.equal(view.record.decisions.files[0].title, 'Adopt the board record') + assert.equal(view.record.meetings.decided.length, 1) + assert.equal(view.record.meetings.decided[0].path, 'decisions/2026-10-30-adopt.md') + assert.equal(view.record.meetings.pending.length, 1) + assert.equal(view.record.meetings.pending[0].status, 'deliberating') +}) + +test('#1767: the pane draws the record under the cards, and a deliberating meeting is never a decision', () => { + const tree = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, decisions, meetings, canManage: false })) + const text = tree.map(textOf).join(' | ') + assert.match(text, /Adopt the board record/) + assert.match(text, /aaaaaaaaaaaa/, 'the hash the reader can compare is on screen') + assert.match(text, /Which board carries the record\?/) + assert.match(text, /Should the board page\?/) + const sections = tree.filter(node => node.props?.className === 'bt-project-record') + assert.equal(sections.length, 1) + assert.equal(sections[0].props['data-decisions'], '2') + assert.equal(sections[0].props['data-meetings'], '2') + const statuses = tree.filter(node => node.props?.['data-status'] !== undefined).map(node => node.props['data-status']) + assert.deepEqual(statuses.sort(), ['decided', 'deliberating']) +}) + +test('#1767: an empty record names the folder it read and the missing decision file', () => { + const view = plain(projectBoardView({ + project: { id: 'project-1', name: 'FlyBot' }, rows: [], canManage: false, + decisions: { dir: '/team/flybot', files: [], reason: 'no decision file in /team/flybot/decisions' }, + meetings: { decided: [], pending: [], reason: 'no meeting of this Project is recorded' }, + })) + assert.equal(view.record.decisions.empty, true) + assert.equal(view.record.decisions.emptyText, 'no decision file in /team/flybot/decisions') + assert.equal(view.record.meetings.empty, true) + assert.equal(view.record.meetings.emptyText, 'no meeting of this Project is recorded') + // The view before this slice still answers: a caller passing no sections reads an empty record, + // not a thrown one. + const bare = plain(projectBoardView({ project: { id: 'p', name: 'P' }, rows: [] })) + assert.equal(bare.record.decisions.count, 0) + assert.match(bare.record.decisions.emptyText, /no working folder/) +}) diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index b9f7c3e4e..b53d4356b 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -2,7 +2,7 @@ // that same id through `run.task.id`. The aggregate reads both views as one row, never twice, // and writes nothing back. import assert from 'node:assert/strict' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' @@ -95,7 +95,7 @@ test('a Project board reads a group card and a run-created card without repeatin { taskId: 'task-missing', runId: 'run-3' }, ].filter(row => hasTask(row.taskId))) try { - const rows = rt.projectBoard('project-p1') + const rows = (await rt.projectBoard('project-p1')).rows assert.equal(rows.length, 2) const group = rows.find(row => row.id === 'task-group') assert.deepEqual(group, { id: 'task-group', source: 'group', column: 'in-progress', assignee: 'Writer', updatedAt: 10, runId: 'run-2', nodeId: 'planner' }) @@ -114,10 +114,10 @@ test('an empty Project answers [] and a missing one is not an error', async () = try { // Without a provider the group board still reads on its own; the run-created card has no // board of its own and no run binding to give, so it is not a row. - assert.deepEqual(rt.projectBoard('project-p1').map(row => [row.id, row.source]), [['task-group', 'group']]) - assert.deepEqual(rt.projectBoard('project-empty'), []) - assert.deepEqual(rt.projectBoard('project-nothing'), []) - assert.deepEqual(rt.projectBoard(''), []) + assert.deepEqual((await rt.projectBoard('project-p1')).rows.map(row => [row.id, row.source]), [['task-group', 'group']]) + assert.deepEqual((await rt.projectBoard('project-empty')).rows, []) + assert.deepEqual((await rt.projectBoard('project-nothing')).rows, []) + assert.deepEqual((await rt.projectBoard('')).rows, []) } finally { await stop() } }) @@ -128,7 +128,7 @@ test('reading the board leaves state.tasks and the run refs byte-identical', asy try { const before = JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }) const refsBefore = JSON.stringify(rt.projectRunRefs()) - rt.projectBoard('project-p1') + await rt.projectBoard('project-p1') assert.equal(JSON.stringify({ tasks: rt.state.tasks, projects: rt.state.projects, projectRuns: rt.state.projectRuns }), before) assert.equal(JSON.stringify(rt.projectRunRefs()), refsBefore) } finally { @@ -179,13 +179,13 @@ test('#1713: a run binding never carries another Project’s card onto a board', const { rt, stop } = await start(undefined, splitSeed()) const off = rt.onProjectProvider(() => [{ taskId: 'task-group', runId: 'run-2' }, { taskId: 'task-run', runId: 'run-1' }]) try { - const p1 = rt.projectBoard('project-p1') + const p1 = (await rt.projectBoard('project-p1')).rows // Rows come newest first, so the identity of a row is its id and source, not its position. assert.deepEqual(p1.map(row => `${row.id}:${row.source}`).sort(), ['task-group:group', 'task-run:workflow']) const group = p1.find(row => row.id === 'task-group') assert.equal(group.runId, undefined, 'a row of this Project never carries another Project’s run id') // Project 2 does not inherit a card whose own board already names Project 1. - assert.deepEqual(rt.projectBoard('project-p2'), [], 'the far Project lists no card of this one') + assert.deepEqual((await rt.projectBoard('project-p2')).rows, [], 'the far Project lists no card of this one') } finally { off() await stop() @@ -229,3 +229,135 @@ test('#1723: a Project board paints only the cards `tasks` would let the member assert.deepEqual(empty.value.tasks, []) } finally { await stop() } }) + +// #1767: the record beside the cards. The Project's working folder holds two decision files and +// one meeting reached `decided`; the board lists exactly those, with the hash it computed, and a +// meeting still deliberating is never listed as a decision. +const recordSeed = () => { + const team = seed() + team.projects['project-p1'].workDir = '__WORKDIR__' + team.meetings = { + 'meeting-decided': { + id: 'meeting-decided', status: 'decided', question: 'Which board carries the record?', + owner: 'Chief', project: 'project-p1', decisionFile: 'decisions/2026-10-30-adopt.md', + createdAt: 1, updatedAt: 5, decidedAt: 6, + }, + 'meeting-open': { + id: 'meeting-open', status: 'deliberating', question: 'Should the board page?', + project: 'P1', createdAt: 2, updatedAt: 3, + }, + 'meeting-blocked': { + id: 'meeting-blocked', status: 'blocked', question: 'Who owns the archive?', + project: 'project-p1', blockingQuestion: 'The maintainer has not named an owner.', + createdAt: 2, updatedAt: 4, + }, + 'meeting-other': { + id: 'meeting-other', status: 'decided', question: 'Another Project’s question', + owner: 'Chief', project: 'project-p2', decisionFile: 'decisions/2026-10-30-elsewhere.md', + createdAt: 1, updatedAt: 2, decidedAt: 3, + }, + } + return team +} + +// The State reader answers for a repository, so the fixture is one; the decisions folder only +// exists once this test makes it, which is the read the board performs. +const folderWithDecisions = () => { + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-decisions-')) + mkdirSync(join(dir, '.git')) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-10-30-adopt.md'), '# Adopt the board record\n\nThe board lists this file.\n') + writeFileSync(join(dir, 'decisions', '2026-10-30-second.md'), '# Keep decisions small\n') + return dir +} + +async function startWithRecord() { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = folderWithDecisions() + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const started = await start(home, team) + return { ...started, home, dir } +} + +test('#1767: the board lists two decisions with their hash and one decided meeting', async () => { + const { rt, call, home, dir, stop } = await startWithRecord() + try { + const board = await rt.projectBoard('project-p1') + assert.equal(board.decisions.dir, dir) + assert.deepEqual(board.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md', 'decisions/2026-10-30-second.md']) + assert.deepEqual(board.decisions.files.map(file => file.date), ['2026-10-30', '2026-10-30']) + assert.deepEqual(board.decisions.files.map(file => file.title), ['Adopt the board record', 'Keep decisions small']) + for (const file of board.decisions.files) { + assert.match(file.sha256, /^[0-9a-f]{64}$/, 'every decision carries the hash the reader computed') + assert.equal(file.bytes, statSync(join(dir, file.path)).size) + assert.equal(file.mtime, new Date(statSync(join(dir, file.path)).mtimeMs).toISOString()) + } + // One decided meeting, linked to its file; the deliberating and blocked ones are apart, and a + // meeting of another Project is not here at all. + assert.deepEqual(board.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) + assert.equal(board.meetings.decided[0].path, 'decisions/2026-10-30-adopt.md') + assert.equal(board.meetings.decided[0].owner, 'Chief') + assert.equal(board.meetings.decided[0].question, 'Which board carries the record?') + assert.deepEqual(board.meetings.pending.map(meeting => meeting.status).sort(), ['blocked', 'deliberating']) + assert.equal(board.meetings.pending.some(meeting => meeting.id === 'meeting-decided'), false, 'a decided meeting is never pending') + assert.equal(board.meetings.decided.some(meeting => meeting.id === 'meeting-open'), false, 'a deliberating meeting is never a decision') + // The same read through the endpoint the page uses: `tasks` unchanged, two new keys. + const served = value(await call('project-board', { id: 'project-p1' })) + assert.deepEqual(served.tasks.map(row => row.id), ['task-group']) + assert.equal(served.decisions.files.length, 2) + assert.deepEqual(served.meetings.decided.map(meeting => meeting.id), ['meeting-decided']) + // Nothing wrote: the team file is the bytes it was when the read began. + const before = readFileSync(join(home, 'state.json')) + const reread = value(await call('project-board', { id: 'project-p1' })) + assert.equal(reread.decisions.files.length, 2) + assert.deepEqual(readFileSync(join(home, 'state.json')), before, 'reading the board writes nothing') + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('#1767: a deleted decision file leaves the next read, and a decided meeting still reads decided', async () => { + const { rt, dir, stop } = await startWithRecord() + try { + rmSync(join(dir, 'decisions', '2026-10-30-second.md')) + const afterDelete = await rt.projectBoard('project-p1') + assert.deepEqual(afterDelete.decisions.files.map(file => file.path), ['decisions/2026-10-30-adopt.md']) + // The archived file the decided meeting names is removed too: the meeting is the record and + // stays listed, with no path — the file is gone, not the decision. + rmSync(join(dir, 'decisions', '2026-10-30-adopt.md')) + const gone = await rt.projectBoard('project-p1') + assert.equal(gone.decisions.files.length, 0) + assert.match(gone.decisions.reason, /no decision file in .*decisions/) + assert.deepEqual(gone.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('#1767: a Project without a decisions folder answers an empty list with the reason, never a throw', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-nodecisions-')) + mkdirSync(join(dir, '.git')) + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const { rt, call, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + assert.deepEqual(board.decisions.files, []) + assert.match(board.decisions.reason, /no decision file in .*decisions/, 'the empty state names the folder it read') + assert.deepEqual(board.meetings.decided.map(meeting => [meeting.id, meeting.path]), [['meeting-decided', '']]) + // A Project naming no folder is an answer too. + const noFolder = await rt.projectBoard('project-empty') + assert.deepEqual(noFolder.decisions.files, []) + assert.match(noFolder.decisions.reason, /no working folder/) + const served = value(await call('project-board', { id: 'project-p1' })) + assert.deepEqual(served.decisions.files, []) + assert.match(served.decisions.reason, /no decision file/) + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/patches/ds-bot/files/test/task-contract.test.js b/patches/ds-bot/files/test/task-contract.test.js index 88c20d878..d0545e488 100644 --- a/patches/ds-bot/files/test/task-contract.test.js +++ b/patches/ds-bot/files/test/task-contract.test.js @@ -225,7 +225,7 @@ test('a pre-slice record reads back and renders without the new fields (#1720)', assert.match(formatRoomBoard(app.rt.state.tasks, { roomId: 'war', now: 10, nameOf: () => 'Dev1' }), /An old card/) }) -test('the Project board reads the typed fields from the same row (#1720)', () => { +test('the Project board reads the typed fields from the same row (#1720)', async () => { const tasks = { 'task-old': { id: 'task-old', status: 'working', ownerBotId: 'b1', title: 'Old', createdAt: 1, updatedAt: 2 }, 'task-new': { @@ -238,7 +238,7 @@ test('the Project board reads the typed fields from the same row (#1720)', () => } const rt = { state: { tasks }, readProject: id => ({ id }), projectOfMember: () => ({ id: 'p' }), taskVisible: () => true } installProjectBoard(rt) - const rows = rt.projectBoard('p') + const rows = (await rt.projectBoard('p')).rows const old = rows.find(row => row.id === 'task-old') const fresh = rows.find(row => row.id === 'task-new') assert.equal(old.taskClass, undefined, 'a pre-slice row keeps its old shape')