From 16ece373c96c99f7eb14afd722e8d28407b053da Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 13:07:47 +0800 Subject: [PATCH 1/5] feat(board): one board, columns from the definition, filters for the stages that draw none (#1326) --- changelog.d/1326.changed.md | 1 + core/client.js | 271 +++++++++++++++++++++++++------ core/src/client/delivery-view.js | 224 ++++++++++++++++++++----- core/src/client/i18n.js | 4 + core/src/client/kanban.js | 51 ++++-- core/src/host/delivery-board.js | 34 ++-- core/test/delivery-board.test.js | 68 ++++---- core/test/delivery-view.test.js | 76 ++++++++- core/test/kanban.test.js | 50 +++--- docs/subsystems/core-client.md | 6 +- docs/subsystems/core-host.md | 2 +- 11 files changed, 602 insertions(+), 185 deletions(-) create mode 100644 changelog.d/1326.changed.md diff --git a/changelog.d/1326.changed.md b/changelog.d/1326.changed.md new file mode 100644 index 000000000..23ddba53a --- /dev/null +++ b/changelog.d/1326.changed.md @@ -0,0 +1 @@ +Unified the task and delivery boards into one "Board" dialog. The Tasks/Delivery tab split is removed; both task cards and GitHub cards now appear together in a single view. diff --git a/core/client.js b/core/client.js index d6f8d8e1a..93db6b363 100644 --- a/core/client.js +++ b/core/client.js @@ -228,6 +228,8 @@ var ZH = { "{from} → {to}": "{from} → {to}", "+{count} more": "另有 {count} 条", delegated: "委托转达", + All: "全部", + Filter: "筛选", Review: "查看", "Attached files": "附件", Retry: "重试", @@ -739,7 +741,9 @@ var ZH = { "Could not comment on {target}: {error}": "无法在 {target} 上留言:{error}", "Could not confirm the comment on {target} went out": "无法确认 {target} 上的留言是否发出", // Bot task board (#1058) + Board: "看板", Tasks: "任务", + "Close board": "关闭看板", "Close tasks": "关闭任务", "Task board": "任务板", "Not started": "未开始", @@ -8452,11 +8456,9 @@ function pollDelay(state, payload = null, now = Date.now()) { return POLL_MS3; } var DELIVERY_COLUMNS = [ - { id: "raw", label: "Original issue" }, - { id: "ready", label: "Ready to start" }, - { id: "working", label: "In progress" }, - { id: "review", label: "In review" }, - { id: "done-today", label: "Done today" } + { id: "not-started", label: "Not started" }, + { id: "in-progress", label: "In progress" }, + { id: "done", label: "Done" } ]; function stagesOf(view) { const columns = view?.columns; @@ -8479,6 +8481,80 @@ var BADGES = { "checks-unknown": "Checks unknown" }; var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; +function taskColumnOf(task) { + const col = columnOf(task); + if (col === "not-started") return "not-started"; + if (col === "in-progress") return "in-progress"; + return "done"; +} +var priorityLabel = (priority) => t(priority === "urgent" ? "Highest" : priority === "high" ? "High" : "Low"); +var taskShortId = (id) => `#${String(id ?? "").replace("task-", "").slice(-6)}`; +function taskCardModel(task, roster) { + return { + source: "task", + key: `task:${task?.id}`, + title: task?.title ?? "", + owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, + ownerKind: "bot" + }; +} +var dropProps = (column, { dragging, onMove, onDrag }) => ({ + onDragOver: (event) => { + if (!dragging) return; + event.preventDefault(); + }, + onDrop: (event) => { + event.preventDefault(); + const id = event.dataTransfer?.getData("text/plain") ?? ""; + if (id) onMove?.(id, column); + onDrag?.(null); + } +}); +var taskCardNode = (task, { roster, onMove, onDrag }) => (0, import_react16.createElement)(BoardCard, { + key: task.id, + card: taskCardModel(task, roster), + skin: TASK_SKIN, + attrs: { "data-task": task.id }, + idText: taskShortId(task.id), + topExtra: task.priority && task.priority !== "normal" ? (0, import_react16.createElement)("span", { className: "bt-board-priority", "data-priority": task.priority }, priorityLabel(task.priority)) : null, + draggable: Boolean(onMove), + dragValue: task.id, + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null) +}); +function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = { "not-started": [], "in-progress": [], "done": [] }; + for (const task of tasks ?? []) { + const col = taskColumnOf(task); + if (columns[col]) columns[col].push(task); + } + const columnTitles = { + "not-started": t("Not started"), + "in-progress": t("In progress"), + "done": t("Done") + }; + return (0, import_react16.createElement)( + "div", + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( + "section", + { + key: column, + className: "bt-board-column", + "data-column": column, + "aria-label": columnTitles[column], + ...dropProps(column, { dragging, onMove, onDrag }) + }, + (0, import_react16.createElement)( + "h3", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, columnTitles[column]), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) + ), + columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => taskCardNode(task, { roster, onMove, onDrag })) + )) + ); +} function duration(ms) { if (!Number.isFinite(ms) || ms < 0) return "—"; const hours = ms / 36e5; @@ -8757,27 +8833,61 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = ) ); } -function DeliveryColumns({ view, repo = null, placed = null }) { +var BOARD_FILTERS = ["all", "review", "done-today"]; +function BoardFilters({ filter, onFilter }) { + return (0, import_react16.createElement)( + "div", + { className: "bt-del-filters", role: "group", "aria-label": t("Filter") }, + BOARD_FILTERS.map((id) => (0, import_react16.createElement)("button", { + key: id, + type: "button", + className: "bt-soft bt-soft-sm", + "data-filter": id, + "aria-pressed": filter === id ? "true" : "false", + onClick: () => onFilter(id) + }, id === "all" ? t("All") : id === "review" ? t("Review") : t("Done today"))) + ); +} +var stageOf = (card, column) => card?.stage ?? column; +var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; +function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {}; const drawn = placed === null || placed === void 0 ? null : new Set(placed); - const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)); + const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); + const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; + for (const task of filter === "all" ? tasks ?? [] : []) { + const col = taskColumnOf(task); + if (tasksByColumn[col]) tasksByColumn[col].push(task); + } + const allCards = (id) => { + const delivery = deliveryCards(id); + const taskList = tasksByColumn[id] ?? []; + return [...taskList, ...delivery]; + }; + const taskIds = new Set((tasks ?? []).map((task) => task?.id)); + const isTask = (item) => taskIds.has(item?.id); return (0, import_react16.createElement)( "div", { className: "bt-del-columns" }, stagesOf(view).map((column) => (0, import_react16.createElement)( "section", - { key: column.id, className: "bt-del-column", "data-column": column.id }, + { + key: column.id, + className: "bt-del-column", + "data-column": column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }) + }, (0, import_react16.createElement)( "h3", { className: "bt-board-column-head" }, (0, import_react16.createElement)("span", null, t(column.label)), - (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards(column.id).length)) + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) ), - cards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards(column.id).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) + allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? taskCardNode(item, { roster, onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) )) ); } -function DeliveryWorkflowSections({ sections, repo = null }) { +function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { const list = Array.isArray(sections) ? sections : []; if (list.length === 0) return null; return (0, import_react16.createElement)("div", { className: "bt-del-workflows" }, list.map((section) => (0, import_react16.createElement)( @@ -8793,27 +8903,30 @@ function DeliveryWorkflowSections({ sections, repo = null }) { (0, import_react16.createElement)("span", null, section.name), (0, import_react16.createElement)("span", { className: "bt-wf-path" }, `workflows/${section.id}.json`) ), - (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => (0, import_react16.createElement)( - "section", - { - key: column.id, - className: "bt-del-column", - "data-column": column.id, - "data-workflow": section.id - }, - (0, import_react16.createElement)( - "h4", - { className: "bt-board-column-head" }, - (0, import_react16.createElement)("span", null, column.label), - (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), - (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(column.cards.length)) - ), - column.cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : column.cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) - ))), + (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => { + const cards = column.cards.filter((card) => matchesFilter(card, column.id, filter)); + return (0, import_react16.createElement)( + "section", + { + key: column.id, + className: "bt-del-column", + "data-column": column.id, + "data-workflow": section.id + }, + (0, import_react16.createElement)( + "h4", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, column.label), + (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards.length)) + ), + cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) + ); + })), (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null ))); } -function DeliveryRepoBlock({ entry }) { +function DeliveryRepoBlock({ entry, filter = "all" }) { const state = entry?.state ?? "fresh"; const source = repoSourceText(entry?.repoSource); return (0, import_react16.createElement)( @@ -8830,10 +8943,10 @@ function DeliveryRepoBlock({ entry }) { { className: "bt-del-block-state", role: "status" }, state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" - ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed }) + ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter }) ); } -function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { +function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); const state = payload.state ?? "fresh"; const retryAt = Number(payload.retryAt); @@ -8846,18 +8959,24 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu return () => clearInterval(timer); }, [ticking, retryAt]); const countdown = retryCountdown(payload.retryAt, now); + const [filter, setFilter] = (0, import_react16.useState)("all"); const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; + const hasTasks = Array.isArray(tasks) && tasks.length > 0; if (state === "idle") { return (0, import_react16.createElement)( "div", - { className: "bt-del-body", "data-view": "delivery", "data-state": state }, + { className: "bt-del-body", "data-view": "board", "data-state": state }, + (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) ); } if (state === "loading") { return (0, import_react16.createElement)( "div", - { className: "bt-del-body", "data-view": "delivery", "data-state": state }, + { className: "bt-del-body", "data-view": "board", "data-state": state }, + (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, (0, import_react16.createElement)("p", { className: "bt-del-loading", role: "status" }, loadingText(payload.elapsedMs)), (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("The first round reads the repository once; the columns appear as soon as it lands.")) ); @@ -8867,7 +8986,9 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu const error = payload.error ?? null; return (0, import_react16.createElement)( "div", - { className: "bt-del-body", "data-view": "delivery", "data-state": state }, + { className: "bt-del-body", "data-view": "board", "data-state": state }, + (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, (0, import_react16.createElement)( "div", { className: "bt-board-error", role: "alert" }, @@ -8885,7 +9006,8 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu const missing = payload.missing ?? []; return (0, import_react16.createElement)( "div", - { className: "bt-del-body", "data-view": "delivery", "data-state": state }, + { className: "bt-del-body", "data-view": "board", "data-state": state }, + (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : null, state === "stale" && payload.error ? (0, import_react16.createElement)( "p", @@ -8898,19 +9020,24 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), // A workflow's own columns come first: a card that names a workflow is drawn there and not in // the five columns below, which keep every card that names none (#1272 step 3). - entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) : null, - entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : (0, import_react16.createElement)( + entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, + entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( "div", { className: "bt-del-repos" }, - entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) + entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, filter })) ) ); } -function DeliveryView({ actions }) { +function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { const [view, retry] = useDeliveryBoard(actions); return (0, import_react16.createElement)(DeliveryBoard, { view, repo: view?.repo ?? null, + tasks, + roster, + dragging, + onMove, + onDrag, onRetry: actions?.deliveryBoard ? retry : null, onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null }); @@ -8920,7 +9047,7 @@ function DeliveryCard({ card, repo = null }) { return (0, import_react16.createElement)(BoardCard, { card: model, skin: DELIVERY_SKIN, - attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0 }, + attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0, "data-stage": card?.stage ?? void 0 }, idText: `#${card?.number ?? "?"}`, badge: badgeLabel(card?.badge) }); @@ -8942,6 +9069,8 @@ var DELIVERY_CSS = ` .bt-del-diag{display:inline-flex;position:relative} .bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} +.bt-del-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} +.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} .bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} @@ -9062,7 +9191,7 @@ function canMoveTask(roster, task) { const bot = roster?.byId?.[task?.ownerBotId]; return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); } -function priorityLabel(priority) { +function priorityLabel2(priority) { if (priority === "urgent") return t("Highest"); if (priority === "high") return t("High"); if (priority === "low") return t("Low"); @@ -9190,7 +9319,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) idText: shortId(task.id), emptyTitle: t("Untitled task"), topExtra: [ - (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), + (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel2(priority)), (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) ], note: task.note ?? "", @@ -9354,8 +9483,49 @@ function scopeName(roster, scope) { if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; return ""; } +function UnifiedBoard({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + const lanes = scope?.kind === "group"; + return (0, import_react17.createElement)( + "div", + { className: "bt-board-body" }, + (0, import_react17.createElement)( + "aside", + { className: "bt-board-rail", "aria-label": t("Task board") }, + (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), + rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + const count = counts2?.[row.id] ?? { done: 0, total: 0 }; + return (0, import_react17.createElement)(RailButton, { + key: row.id, + row, + selected: row.id === selectedBot, + onSelect, + meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" + }); + }), + rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, + rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) + ), + (0, import_react17.createElement)( + "div", + { className: "bt-board-main" }, + scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( + "form", + { className: "bt-board-add", onSubmit: (event) => { + event.preventDefault(); + onCreate(draft); + } }, + (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), + (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) + ) : null, + error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, + (0, import_react17.createElement)(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }) + ) + ); +} function KanbanDialog({ roster, actions, leaving = false }) { - const [view, setView] = (0, import_react17.useState)("tasks"); const rail = boardRail(roster); const [scope, setScope] = (0, import_react17.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); const [dragging, setDragging] = (0, import_react17.useState)(null); @@ -9405,18 +9575,12 @@ function KanbanDialog({ roster, actions, leaving = false }) { (0, import_react17.createElement)( "div", { className: "bt-mem-head" }, - (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, view === "delivery" ? t("Delivery") : t("Tasks")), - (0, import_react17.createElement)( - "div", - { className: "bt-board-views", role: "tablist", "aria-label": t("Board view") }, - (0, import_react17.createElement)("button", { type: "button", role: "tab", className: "bt-soft bt-soft-sm", "data-view": "tasks", "aria-selected": view === "tasks" ? "true" : "false", onClick: () => setView("tasks") }, t("Tasks")), - (0, import_react17.createElement)("button", { type: "button", role: "tab", className: "bt-soft bt-soft-sm", "data-view": "delivery", "aria-selected": view === "delivery" ? "true" : "false", onClick: () => setView("delivery") }, t("Delivery")) - ), - view === "tasks" ? (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name) : null, + (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, t("Board")), + (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name), (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close tasks"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) + (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) ), - view === "delivery" ? (0, import_react17.createElement)(DeliveryView, { actions }) : (0, import_react17.createElement)(BoardView, { + (0, import_react17.createElement)(UnifiedBoard, { roster, tasks: boardTasks, rail, @@ -9425,6 +9589,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { dragging, error, draft, + actions, onDraft: setDraft, onSelect: setScope, onMove: move, diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 1a8e49919..2b3a0004d 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -6,11 +6,15 @@ // `loading` counts the seconds, `failed` names the endpoint, the status and what to do about it, // `stale` keeps the old board and hangs the reason beside it, `partial` says which part was not // read. The poll is fast while a round is running (2 s) and slow once there is something to read. +// +// #1326: One board that shows both task cards and delivery cards together, columns from workflow +// definitions, filters for stages with column:false (review, done-today). import { createElement as h, useEffect, useState } from 'react' import { t } from './i18n.js' +import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN } from './board-card.js' +import { BoardCard, DELIVERY_SKIN, TASK_SKIN } from './board-card.js' const POLL_MS = 60_000 // While the host is still building there is nothing to see, so ask often; once the board is up, @@ -32,12 +36,12 @@ export function pollDelay(state, payload = null, now = Date.now()) { return POLL_MS } +// #1326: Columns from workflow definitions. The three visible stages of repo-dev. +// Stages with column:false (review, done-today) are filters, not columns. export const DELIVERY_COLUMNS = [ - { id: 'raw', label: 'Original issue' }, - { id: 'ready', label: 'Ready to start' }, - { id: 'working', label: 'In progress' }, - { id: 'review', label: 'In review' }, - { id: 'done-today', label: 'Done today' }, + { id: 'not-started', label: 'Not started' }, + { id: 'in-progress', label: 'In progress' }, + { id: 'done', label: 'Done' }, ] // The columns to draw for one payload, in one place (#1265 step 4). The snapshot names which @@ -68,6 +72,84 @@ const BADGES = { export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '') +// #1326: Task cards in workflow columns. Task status maps to column: open/claimed → not-started, +// working/blocked/result-ready → in-progress, accepted/cancelled/failed → done. +function taskColumnOf(task) { + const col = columnOf(task) + if (col === 'not-started') return 'not-started' + if (col === 'in-progress') return 'in-progress' + return 'done' +} + +// A task on the one board reads through the same card a delivery card does (#1265 step 1, #1326): +// one identity line, one title, and the Bot that has it — only the source differs. +const priorityLabel = (priority) => t(priority === 'urgent' ? 'Highest' : priority === 'high' ? 'High' : 'Low') +const taskShortId = (id) => `#${String(id ?? '').replace('task-', '').slice(-6)}` +function taskCardModel(task, roster) { + return { + source: 'task', + key: `task:${task?.id}`, + title: task?.title ?? '', + owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, + ownerKind: 'bot', + } +} + +// A column takes a card dropped on it, and the board's own `onMove` decides whether that move is +// allowed — the drop is a request, never a write from the client (#1326). +const dropProps = (column, { dragging, onMove, onDrag }) => ({ + onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, + onDrop: (event) => { + event.preventDefault() + const id = event.dataTransfer?.getData('text/plain') ?? '' + if (id) onMove?.(id, column) + onDrag?.(null) + }, +}) + +const taskCardNode = (task, { roster, onMove, onDrag }) => h(BoardCard, { + key: task.id, + card: taskCardModel(task, roster), + skin: TASK_SKIN, + attrs: { 'data-task': task.id }, + idText: taskShortId(task.id), + topExtra: task.priority && task.priority !== 'normal' + ? h('span', { className: 'bt-board-priority', 'data-priority': task.priority }, priorityLabel(task.priority)) + : null, + draggable: Boolean(onMove), + dragValue: task.id, + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null), +}) + +// Simplified task column renderer for degraded states (no GitHub snapshot). +function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = { 'not-started': [], 'in-progress': [], 'done': [] } + for (const task of tasks ?? []) { + const col = taskColumnOf(task) + if (columns[col]) columns[col].push(task) + } + const columnTitles = { + 'not-started': t('Not started'), + 'in-progress': t('In progress'), + 'done': t('Done'), + } + return h('div', { className: 'bt-board-columns' }, + ['not-started', 'in-progress', 'done'].map(column => h('section', { + key: column, + className: 'bt-board-column', + 'data-column': column, + 'aria-label': columnTitles[column], + ...dropProps(column, { dragging, onMove, onDrag }), + }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, columnTitles[column]), + h('span', { className: 'bt-board-count' }, String(columns[column].length))), + columns[column].length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) + : columns[column].map(task => taskCardNode(task, { roster, onMove, onDrag }))))) +} + export function duration(ms) { if (!Number.isFinite(ms) || ms < 0) return '—' const hours = ms / 3_600_000 @@ -357,28 +439,75 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on onProbe ? h(Diagnose, { onProbe }) : null)) } -// One repository's columns (#1265 step 4). The card key carries the repository, so the same issue -// number in two repositories is two cards and neither is mistaken for the other. -function DeliveryColumns({ view, repo = null, placed = null }) { +// #1326: the stages a definition draws no column for — a review, today's finished cards — are +// filters, not lanes. A filter narrows which cards a column shows; it never reorders the columns, and +// one board keeps one set of them. +const BOARD_FILTERS = ['all', 'review', 'done-today'] +function BoardFilters({ filter, onFilter }) { + return h('div', { className: 'bt-del-filters', role: 'group', 'aria-label': t('Filter') }, + BOARD_FILTERS.map(id => h('button', { + key: id, + type: 'button', + className: 'bt-soft bt-soft-sm', + 'data-filter': id, + 'aria-pressed': filter === id ? 'true' : 'false', + onClick: () => onFilter(id), + }, id === 'all' ? t('All') : id === 'review' ? t('Review') : t('Done today')))) +} + +const stageOf = (card, column) => card?.stage ?? column +const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter + +// One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. +function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {} // A card a workflow section already draws is not drawn again here (#1272 step 3): the section owns // it, and the five legacy columns keep every card that names no workflow. const drawn = placed === null || placed === undefined ? null : new Set(placed) - const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) + const deliveryCards = (id) => (columns[id] ?? []) + .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) + .filter((card) => matchesFilter(card, id, filter)) + + // #1326: Task cards map into the same columns. Map task column to delivery column. + const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } + // A task card names no review or done-today stage, so a stage filter narrows it away rather than + // moving it to a lane it never named (#1326). + for (const task of filter === 'all' ? tasks ?? [] : []) { + const col = taskColumnOf(task) + if (tasksByColumn[col]) tasksByColumn[col].push(task) + } + + const allCards = (id) => { + const delivery = deliveryCards(id) + const taskList = tasksByColumn[id] ?? [] + return [...taskList, ...delivery] + } + + // The two kinds share these columns, so which one a card is has to come from where it was read, + // never from guessing at its fields. + const taskIds = new Set((tasks ?? []).map(task => task?.id)) + const isTask = (item) => taskIds.has(item?.id) + return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, + stagesOf(view).map(column => h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }), + }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(cards(column.id).length))), - cards(column.id).length === 0 + h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), + allCards(column.id).length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : cards(column.id).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) + : allCards(column.id).map(item => + isTask(item) + ? taskCardNode(item, { roster, onMove, onDrag }) + : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, // the columns in the definition's own order, and every header naming the workflow it belongs to — so // two workflows never share a column and a reader can always tell whose column it is. -function DeliveryWorkflowSections({ sections, repo = null }) { +function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { const list = Array.isArray(sections) ? sections : [] if (list.length === 0) return null return h('div', { className: 'bt-del-workflows' }, list.map(section => h('section', { @@ -387,16 +516,19 @@ function DeliveryWorkflowSections({ sections, repo = null }) { h('h3', { className: 'bt-del-workflow-name' }, h('span', null, section.name), h('span', { className: 'bt-wf-path' }, `workflows/${section.id}.json`)), - h('div', { className: 'bt-del-columns' }, section.columns.map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, - }, - h('h4', { className: 'bt-board-column-head' }, - h('span', null, column.label), - h('span', { className: 'bt-del-workflow-tag' }, section.name), - h('span', { className: 'bt-board-count' }, String(column.cards.length))), - column.cards.length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : column.cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))))), + h('div', { className: 'bt-del-columns' }, section.columns.map(column => { + const cards = column.cards.filter(card => matchesFilter(card, column.id, filter)) + return h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, + }, + h('h4', { className: 'bt-board-column-head' }, + h('span', null, column.label), + h('span', { className: 'bt-del-workflow-tag' }, section.name), + h('span', { className: 'bt-board-count' }, String(cards.length))), + cards.length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))) + })), (section.unplaced ?? []).length > 0 ? h('p', { className: 'bt-del-hint' }, t('{count} card(s) name a node this workflow draws no column for.', { count: section.unplaced.length })) : null))) @@ -404,7 +536,7 @@ function DeliveryWorkflowSections({ sections, repo = null }) { // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry }) { +function DeliveryRepoBlock({ entry, filter = 'all' }) { const state = entry?.state ?? 'fresh' const source = repoSourceText(entry?.repoSource) return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, @@ -414,10 +546,10 @@ function DeliveryRepoBlock({ entry }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter })) } -export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { +export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' // The countdown promises a number that moves, so the screen redraws once a second while a retry @@ -433,17 +565,25 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet return () => clearInterval(timer) }, [ticking, retryAt]) const countdown = retryCountdown(payload.retryAt, now) + // The filter is view state: it narrows which cards the columns show and never reorders them (#1326). + const [filter, setFilter] = useState('all') // More than one repository is one block per repository; exactly one is today's single board. const entries = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. + // #1326: Task cards still render when delivery fails. + const hasTasks = Array.isArray(tasks) && tasks.length > 0 if (state === 'idle') { - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : h('p', { className: 'bt-board-empty' }, t('No snapshot yet.'))) } if (state === 'loading') { - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, h('p', { className: 'bt-del-loading', role: 'status' }, loadingText(payload.elapsedMs)), h('p', { className: 'bt-del-hint' }, t('The first round reads the repository once; the columns appear as soon as it lands.'))) } @@ -452,7 +592,9 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet const hasBoard = Boolean(payload.columns) || (entries !== null && entries.some(entry => entry.columns !== undefined)) if (state === 'failed' || !hasBoard) { const error = payload.error ?? null - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, h('div', { className: 'bt-board-error', role: 'alert' }, h('p', null, String(error?.message ?? message ?? t('The delivery board is not ready yet.'))), error ? h('p', { className: 'bt-del-hint' }, hintFor(error, { repo })) : null, @@ -463,7 +605,8 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null)) } const missing = payload.missing ?? [] - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, state === 'stale' && payload.error ? h('p', { className: 'bt-del-stale', role: 'status' }, staleText(payload.asOf, payload.error), @@ -477,19 +620,24 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet // A workflow's own columns come first: a card that names a workflow is drawn there and not in // the five columns below, which keep every card that names none (#1272 step 3). entries === null - ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) + ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, filter })))) } -export function DeliveryView({ actions }) { +export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { const [view, retry] = useDeliveryBoard(actions) return h(DeliveryBoard, { view, repo: view?.repo ?? null, + tasks, + roster, + dragging, + onMove, + onDrag, onRetry: actions?.deliveryBoard ? retry : null, onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null, }) @@ -502,7 +650,7 @@ export function DeliveryCard({ card, repo = null }) { return h(BoardCard, { card: model, skin: DELIVERY_SKIN, - attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined }, + attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined, 'data-stage': card?.stage ?? undefined }, idText: `#${card?.number ?? '?'}`, badge: badgeLabel(card?.badge), }) @@ -525,6 +673,8 @@ export const DELIVERY_CSS = ` .bt-del-diag{display:inline-flex;position:relative} .bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} +.bt-del-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} +.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} .bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 92054b7f4..f351948e9 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -194,6 +194,8 @@ const ZH = { '{from} → {to}': '{from} → {to}', '+{count} more': '另有 {count} 条', delegated: '委托转达', + All: '全部', + Filter: '筛选', Review: '查看', 'Attached files': '附件', Retry: '重试', @@ -682,7 +684,9 @@ const ZH = { 'Could not comment on {target}: {error}': '无法在 {target} 上留言:{error}', 'Could not confirm the comment on {target} went out': '无法确认 {target} 上的留言是否发出', // Bot task board (#1058) + Board: '看板', Tasks: '任务', + 'Close board': '关闭看板', 'Close tasks': '关闭任务', 'Task board': '任务板', 'Not started': '未开始', diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index aac525cfe..cee4c91db 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -358,10 +358,36 @@ function scopeName(roster, scope) { return '' } +// #1326: one board that shows both task cards and delivery cards together, no tab split. +// Columns come from workflow definitions; task cards map into the same columns as delivery cards. +function UnifiedBoard({ roster, tasks, rail, scope, counts, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { + const selectedBot = scope?.kind === 'bot' ? scope.botId : '' + const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' + const lanes = scope?.kind === 'group' + return h('div', { className: 'bt-board-body' }, + h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, + h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), + rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { + const count = counts?.[row.id] ?? { done: 0, total: 0 } + return h(RailButton, { + key: row.id, row, selected: row.id === selectedBot, onSelect, + meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', + }) + }), + rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, + rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), + h('div', { className: 'bt-board-main' }, + scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, + h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), + h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, + error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, + lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, + h(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }))) +} + export function KanbanDialog({ roster, actions, leaving = false }) { - // Two views on one surface: the per-Bot task board (#1058) and the repository's delivery - // board (#1173). Delivery is repo-wide, so its rail would be a lie — it is not rendered. - const [view, setView] = useState('tasks') + // One board (#1326): task cards and delivery cards together, no tab split. The rail is per-Bot + // for task scope; delivery is repo-wide and reads from actions.deliveryBoard. const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) @@ -405,19 +431,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('div', { className: 'bt-scrim', onClick: close }), h('div', { className: 'bt-mem bt-board', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'bt-board-heading' }, h('div', { className: 'bt-mem-head' }, - h('h2', { id: 'bt-board-heading' }, view === 'delivery' ? t('Delivery') : t('Tasks')), - h('div', { className: 'bt-board-views', role: 'tablist', 'aria-label': t('Board view') }, - h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'tasks', 'aria-selected': view === 'tasks' ? 'true' : 'false', onClick: () => setView('tasks') }, t('Tasks')), - h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'delivery', 'aria-selected': view === 'delivery' ? 'true' : 'false', onClick: () => setView('delivery') }, t('Delivery'))), - view === 'tasks' ? h('span', { className: 'bt-mem-status' }, name) : null, + h('h2', { id: 'bt-board-heading' }, t('Board')), + h('span', { className: 'bt-mem-status' }, name), h('span', { className: 'bt-mem-head-gap' }), - h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close tasks'), autoFocus: true, onClick: close }, h(CloseIcon))), - view === 'delivery' - ? h(DeliveryView, { actions }) - : h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, - onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, - }))) + h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), + h(UnifiedBoard, { + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, actions, + onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, + }))) } export const KANBAN_CSS = ` diff --git a/core/src/host/delivery-board.js b/core/src/host/delivery-board.js index 5310e339d..817a572bb 100644 --- a/core/src/host/delivery-board.js +++ b/core/src/host/delivery-board.js @@ -14,7 +14,8 @@ import { repoSlug } from './update.js' import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' import { boardSections } from '../shared/workflow-board.js' -export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] +// #1326: Workflow-based columns. The three visible stages of repo-dev; review and done-today are filters. +export const DELIVERY_COLUMNS = ['not-started', 'in-progress', 'done'] // State labels first on a card, then the areas; the order is the order the board shows. const STATE_LABELS = ['needs-human', 'needs-info', 'agent-ready', 'agent-working', 'agent-pr', 'awaiting-approval', 'hold'] @@ -145,12 +146,14 @@ export function cardOf(row, { kind, linked = null, owner = null, badge = null, s } } +// #1326: Map delivery stage to workflow columns. PRs are in-progress (build/verify/gate); +// review is a filter (column:false). Issues map by labels. export function columnOfDelivery(row, { kind }) { const labels = labelNames(row) - if (kind === 'pr') return 'review' - if (labels.includes('agent-working')) return 'working' - if (labels.includes('agent-ready')) return 'ready' - return 'raw' + if (kind === 'pr') return 'in-progress' + if (labels.includes('agent-working') || labels.includes('agent-pr')) return 'in-progress' + if (labels.includes('agent-ready')) return 'not-started' + return 'not-started' } // Which part of the board a path belongs to. The view turns this into advice a person can act on @@ -543,7 +546,8 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = let mergedToday = [] let todayMetrics = null let weekMetrics = null - const columns = { raw: [], ready: [], working: [], review: [], 'done-today': [] } + // #1326: Three workflow columns; done-today cards go in 'done' with data-stage='done-today' for filtering. + const columns = { 'not-started': [], 'in-progress': [], 'done': [] } try { // The two open lists are the board itself: when they cannot be read there is no board to // show, so that failure is the round's, not a part's (#1211). @@ -620,7 +624,13 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = if (error?.budget) { note('budget', error); break } note(`comments:${pr.number}`, error) } - columns.review.push({ ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), badgeBasis: basis }) + // An open pull request is work in progress, and `review` is a stage a card carries: the + // definition draws no column for it (#1326), so the card is placed by `columnOfDelivery`. + columns[columnOfDelivery(pr, { kind: 'pr' })].push({ + ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), + badgeBasis: basis, + stage: 'review', + }) } const closedInDay = (row) => Date.parse(row.closed_at ?? '') >= today const doneIssues = closedToday.filter(row => !isPull(row) && closedInDay(row)) @@ -632,10 +642,12 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = // settle it even when that row carries no merge time. const mergedNumbers = new Set(donePulls.map(row => row.number)) const abandonedPulls = closedToday.filter(row => isPull(row) && !row.pull_request?.merged_at && !mergedNumbers.has(row.number) && closedInDay(row)) - for (const row of donePulls) columns['done-today'].push(cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null })) - for (const row of abandonedPulls) columns['done-today'].push(cardOf(row, { kind: 'pr' })) - for (const row of doneIssues) columns['done-today'].push(cardOf(row, { kind: 'issue' })) - columns['done-today'] = columns['done-today'].slice(0, MAX_DONE) + // #1326: done-today cards go in 'done' column with stage='done-today' for filtering. + for (const row of donePulls) columns.done.push({ ...cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }), stage: 'done-today' }) + for (const row of abandonedPulls) columns.done.push({ ...cardOf(row, { kind: 'pr' }), stage: 'done-today' }) + for (const row of doneIssues) columns.done.push({ ...cardOf(row, { kind: 'issue' }), stage: 'done-today' }) + // done column holds all done cards; done-today is a filter, not a separate column. + columns.done = columns.done.slice(0, MAX_DONE) const needsHuman = issues.filter(row => labelNames(row).includes('needs-human') || labelNames(row).includes('needs-info')).length const openPrs = pullRows.length return { diff --git a/core/test/delivery-board.test.js b/core/test/delivery-board.test.js index f4c97c9f2..929c4fa2a 100644 --- a/core/test/delivery-board.test.js +++ b/core/test/delivery-board.test.js @@ -223,8 +223,9 @@ test('an unreadable /actions/runs leaves the board, and the CI number unknown no assert.equal(result.partial, true) assert.equal(result.ok, true) // The board is still there: one failed request empties nothing. - assert.deepEqual(result.columns.working.map(card => card.number), [12]) - assert.deepEqual(result.counts.review, 2) + // The column keeps its cards and the two open pull requests; only CI is missing. + assert.deepEqual(result.columns['in-progress'].map(card => card.number), [12, 40, 41]) + assert.deepEqual(result.counts['in-progress'], 3) // Unknown is null, never 0 — and the reason is named. assert.equal(result.metrics.today.ci, null) assert.equal(result.metrics.week.ci, null) @@ -236,7 +237,7 @@ test('an unreadable /actions/runs leaves the board, and the CI number unknown no test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async () => { const { board } = build({ failures: { '/commits/bbbbbbb2/check-runs': { status: 404 } } }) const result = await settle(board) - const card = result.columns.review.find(item => item.number === 41) + const card = result.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'checks-unknown') assert.equal(result.state, 'partial') const entry = result.missing.find(item => item.part === 'checks:41') @@ -246,7 +247,7 @@ test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async test('unreadable comments cost that card its owner, not the board (#1211)', async () => { const { board } = build({ failures: { '/issues/12/comments': { status: 404 } } }) const result = await settle(board) - const card = result.columns.working.find(item => item.number === 12) + const card = result.columns['in-progress'].find(item => item.number === 12) assert.equal(card.owner, null) assert.equal(result.state, 'partial') assert.ok(result.missing.some(item => item.part === 'comments:12')) @@ -267,7 +268,7 @@ test('a failed refresh keeps the old snapshot and its reason (#1211)', async () assert.equal(stale.state, 'stale') assert.equal(stale.error.status, 404) assert.equal(stale.asOf, first.asOf, 'the old snapshot is still served') - assert.equal(stale.columns.working.length, 1) + assert.equal(stale.columns['in-progress'].length, 3) }) test('after a failure the next round waits 10 s, then 30 s, then two minutes (#1211)', async () => { @@ -307,7 +308,7 @@ test('a round past its budget lands as partial, not as a failure (#1211)', async assert.equal(result.state, 'partial') assert.equal(result.ok, true) assert.equal(result.partial, true) - assert.ok(Array.isArray(result.columns.raw)) + assert.ok(Array.isArray(result.columns['not-started'])) assert.equal(BUILD_BUDGET_MS, 90_000) }) @@ -347,21 +348,20 @@ test('the --probe CLI is a shell over probeGitHub, not a second implementation ( assert.doesNotMatch(source, /api\.github\.com/, 'the CLI makes no request of its own') }) -test('five columns come from one snapshot, an issue and its PR linked (#1173)', async () => { +test('three workflow columns from one snapshot, an issue and its PR linked (#1173, #1326)', async () => { const { board } = build() const snapshot = await settle(board) assert.deepEqual(Object.keys(snapshot.columns), DELIVERY_COLUMNS) - assert.deepEqual(snapshot.columns.raw.map(card => card.number), [10, 13]) - assert.deepEqual(snapshot.columns.ready.map(card => card.number), [11]) - assert.deepEqual(snapshot.columns.working.map(card => card.number), [12]) - assert.deepEqual(snapshot.columns.review.map(card => card.number), [40, 41]) - assert.deepEqual(snapshot.columns['done-today'].map(card => card.number), [41, 14]) - const working = snapshot.columns.working[0] - assert.equal(working.linked, 41) - assert.equal(working.owner, null) - const ready = snapshot.columns.ready[0] - assert.deepEqual(ready.labels, ['agent-ready', 'enhancement']) - assert.deepEqual(snapshot.counts, { raw: 2, ready: 1, working: 1, review: 2, 'done-today': 2 }) + // #1326: Three workflow columns. Issues map to not-started, PRs to in-progress, done-today in done. + assert.deepEqual(snapshot.columns['not-started'].map(card => card.number).sort((a, b) => a - b), [10, 11, 13]) + assert.deepEqual(snapshot.columns['in-progress'].map(card => card.number).sort((a, b) => a - b), [12, 40, 41]) + assert.deepEqual(snapshot.columns.done.filter(c => c.stage === 'done-today').map(card => card.number).sort((a, b) => a - b), [14, 41]) + const progress = snapshot.columns['in-progress'].find(c => c.number === 12) + assert.equal(progress.linked, 41) + assert.equal(progress.owner, null) + const notStarted = snapshot.columns['not-started'].find(c => c.number === 11) + assert.deepEqual(notStarted.labels, ['agent-ready', 'enhancement']) + assert.deepEqual(snapshot.counts, { 'not-started': 3, 'in-progress': 3, done: 2 }) }) test('only a closing keyword links a pull request to an issue (#1189)', async () => { @@ -375,10 +375,10 @@ test('only a closing keyword links a pull request to an issue (#1189)', async () const card = (column, number) => snapshot.columns[column].find(item => item.number === number) // "also see #10, same as #12" is prose: #10 links to nothing, and #12 keeps the pull request // that closes it (41), not the one that only mentions it. - assert.equal(card('raw', 10).linked, null) - assert.equal(card('working', 12).linked, 41) + assert.equal(card('not-started', 10).linked, null) + assert.equal(card('in-progress', 12).linked, 41) // A closing keyword with a colon counts; nothing else does. - assert.equal(card('raw', 13).linked, 44) + assert.equal(card('not-started', 13).linked, 44) }) test('a pull request closed today without a merge is done today, not nowhere (#1173)', async () => { @@ -390,7 +390,7 @@ test('a pull request closed today without a merge is done today, not nowhere (#1 ], }) const snapshot = await settle(board) - const done = snapshot.columns['done-today'] + const done = snapshot.columns.done // Acceptance 2.5: today's merges plus today's closures, and a closed pull request counts even // though it never merged. Yesterday's closure stays off the board. assert.equal(done.filter(card => card.number === 41).length, 1) @@ -401,7 +401,7 @@ test('a pull request closed today without a merge is done today, not nowhere (#1 // /issues?state=closed returns no head for a pull, so this card carries no merge commit. assert.equal(closed.sha, null) assert.equal(done.some(card => card.number === 53), false) - assert.equal(snapshot.counts['done-today'], done.length) + assert.equal(snapshot.counts.done, done.length) }) test('a pull merged today is done once although the closed page lists it too (#1173)', async () => { @@ -418,10 +418,10 @@ test('a pull merged today is done once although the closed page lists it too (#1 ], }) const snapshot = await settle(board) - const done = snapshot.columns['done-today'] + const done = snapshot.columns.done for (const number of [41, 44, 14, 52]) assert.equal(done.filter(card => card.number === number).length, 1, `#${number} is done today once`) assert.equal(done.some(card => card.number === 15), false) - assert.equal(snapshot.counts['done-today'], done.length) + assert.equal(snapshot.counts.done, done.length) }) test('a verdict with no Attestation still counts at the sha it names (#1173)', async () => { @@ -430,7 +430,7 @@ test('a verdict with no Attestation still counts at the sha it names (#1173)', a checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'pass-enqueue') }) @@ -442,7 +442,7 @@ test('a verdict line wins over an Attestation that names an older sha (#1173)', checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'pass-enqueue') }) @@ -460,7 +460,7 @@ test('a verdict on a later page of comments still decides the badge (#1189)', as checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'inc') assert.ok(github.calls.some(call => call.path === '/issues/41/comments' && /[?&]page=2(&|$)/.test(call.query))) }) @@ -519,7 +519,7 @@ test('a PR badge reads the verdict at its own tip, and CI when there is none (#1 checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const badges = Object.fromEntries(snapshot.columns.review.map(card => [card.number, card.badge])) + const badges = Object.fromEntries(snapshot.columns['in-progress'].map(card => [card.number, card.badge])) // Verdict names an older sha: it does not count at this tip, so CI speaks. assert.equal(badges[40], 'ci-running') assert.equal(badges[41], 'pass-enqueue') @@ -983,9 +983,9 @@ test('two repositories are two blocks of one board, each with its own source (#1 assert.ok(two.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/two'))) // The union carries both repositories' cards: the same issue number in two repositories is two // cards, and the second one does not disappear behind the first. - assert.deepEqual(settled.repos[0].columns.raw.map(card => card.number).sort(), [10, 13]) - assert.deepEqual(settled.repos[1].columns.raw.map(card => card.number).sort(), [10, 13]) - assert.equal(settled.columns.raw.length, 4, 'the same number from each repository is its own card') + assert.deepEqual(settled.repos[0].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) + assert.deepEqual(settled.repos[1].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) + assert.equal(settled.columns['not-started'].length, 6, 'the same number from each repository is its own card') // The header's numbers are the repositories' sums; a median across repositories is not a median, // so it is unknown instead of invented. assert.equal(settled.metrics.today.newIssues, @@ -1039,8 +1039,8 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi const stamp = '2026-10-25T04:00:00Z' const view = (repo, extra = {}) => ({ ok: true, state: 'fresh', repo, repoSource: 'config', asOf: stamp, since: stamp, attempt: 1, elapsedMs: 10, - computedMs: 10, columns: { raw: [], ready: [], working: [], review: [], 'done-today': [] }, - counts: { raw: 0, ready: 0, working: 0, review: 0, 'done-today': 0 }, + computedMs: 10, columns: { 'not-started': [], 'in-progress': [], done: [] }, + counts: { 'not-started': 0, 'in-progress': 0, done: 0 }, metrics: { dayStart: stamp, timeZone: 'Asia/Shanghai', today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: 1, medianPullMergeMs: 2, ci: { failed: 0, total: 1, counted: 1, superseded: 0 } }, diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index c7d976392..544d4faa8 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -143,8 +143,25 @@ const snapshot = { }, } -test('five delivery columns, localized labels, badge wording (#1173)', () => { - assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['raw', 'ready', 'working', 'review', 'done-today']) +// #1326: the host draws the definition's three lanes; `review` and `done-today` are stages a card +// carries, not lanes of their own. +const threeLane = { + ...snapshot, + columns: { + 'not-started': [ + { kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }, + { kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }, + ], + 'in-progress': [ + { kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }, + { kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1', stage: 'review' }, + ], + done: [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40', stage: 'done-today' }], + }, +} + +test('three delivery columns, localized labels, badge wording (#1173, #1326)', () => { + assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['not-started', 'in-progress', 'done']) assert.equal(badgeLabel('inc'), 'INC · rework') assert.equal(badgeLabel('pass-enqueue'), 'PASS · waiting for enqueue') assert.equal(badgeLabel('pass-merge'), 'PASS · waiting for merge') @@ -645,15 +662,62 @@ const repoSample = (repo, extra = {}) => ({ ...extra, }) -test('the visible columns come from the payload through one entry point (#1265)', () => { - assert.equal(stagesOf({ columns: snapshot.columns }).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) - // The snapshot says which columns exist; a definition that ships three draws three, not five. - assert.equal(stagesOf({ columns: { raw: [], working: [], 'done-today': [] } }).map(column => column.id).join('|'), 'raw|working|done-today') +test('the visible columns come from the payload through one entry point (#1265, #1326)', () => { + // The payload names the columns it has; the default is the definition's three lanes. + assert.equal(stagesOf({ columns: threeLane.columns }).map(column => column.id).join('|'), 'not-started|in-progress|done') // An id this build has no label for still renders under its own id rather than disappearing. assert.equal(stagesOf({ columns: { review: [], triage: [] } }).map(column => column.id).join('|'), 'review|triage') assert.equal(stagesOf({}).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) }) +test('#1326: the stages that draw no lane are filters, and a filter narrows without reordering', () => { + const board = mount(DeliveryBoard, { snapshot: threeLane, message: '' }) + // The definition ships three lanes; review and done-today are stages, so no fourth or fifth lane. + const lanes = (tree) => walk(tree).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => node.props['data-column']) + assert.deepEqual(lanes(board.draw()), ['not-started', 'in-progress', 'done']) + const bar = walk(board.draw()).find(node => node.props?.className === 'bt-del-filters') + assert.ok(bar, 'the board draws one filter bar') + const filterButtons = walk(bar).filter(node => node.type === 'button') + assert.deepEqual(filterButtons.map(node => node.props['data-filter']), ['all', 'review', 'done-today']) + assert.deepEqual(filterButtons.map(node => node.props['aria-pressed']), ['true', 'false', 'false']) + // A finished card keeps the stage it came with, so a stage assertion needs no fifth lane. + const done = walk(board.draw()).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') + assert.equal(walk(done).some(node => node.props?.['data-stage'] === 'done-today'), true) + // Pressing a filter narrows the cards and leaves the columns in their order. + filterButtons[1].props.onClick() + const reviewOnly = board.draw() + assert.deepEqual(lanes(reviewOnly), ['not-started', 'in-progress', 'done']) + const pressed = walk(reviewOnly).find(node => node.props?.className === 'bt-del-filters') + assert.deepEqual(walk(pressed).filter(node => node.type === 'button').map(node => node.props['aria-pressed']), ['false', 'true', 'false']) + const shown = walk(reviewOnly).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => walk(node).filter(item => item.props?.['data-number']).map(item => item.props['data-number'])) + assert.deepEqual(shown, [[], [41], []]) +}) + +test('#1326: a task card is the shared card, and a drop only asks the board to move it', () => { + const moved = [] + const board = mount(DeliveryBoard, { + view: { ...threeLane, state: 'fresh' }, + tasks: [{ id: 'task-abc123456', title: 'Draft the note', status: 'working', ownerBotId: 's', column: 'in-progress', priority: 'high' }], + roster: { byId: { s: { name: 'Writer' } } }, + dragging: 'task-abc123456', + onMove: (id, column) => moved.push([id, column]), + onDrag: (id) => moved.push(['drag', id]), + }) + const tree = board.draw() + const card = walk(tree).find(node => node.props?.['data-task'] === 'task-abc123456') + assert.equal(card.props['data-source'], 'task') + assert.equal(card.props['data-key'], 'task:task-abc123456') + assert.match(text(card), /Draft the note/) + assert.match(text(card), /Writer/, 'the card names the Bot that has the task') + assert.match(text(card), /High/) + // Dragging reports the card, and the drop reports the column it landed on: the host decides, the + // view never writes (#1326). + card.props.onDragStart({ dataTransfer: { setData: () => {} } }) + const done = walk(tree).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') + done.props.onDrop({ preventDefault: () => {}, dataTransfer: { getData: () => 'task-abc123456' } }) + assert.deepEqual(moved, [['drag', 'task-abc123456'], ['task-abc123456', 'done'], ['drag', null]]) +}) + test('more than one repository draws one block each, one repository draws none (#1265)', () => { // One repository keeps today's shape: the header names it and no block wrapper is rendered. const single = DeliveryBoard({ snapshot: { ...snapshot, repo: 'acme/one' }, repo: 'acme/one', message: '' }) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index a64ff0612..6900c62e3 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -175,31 +175,30 @@ test('a group column nests subtasks under the overall task', () => { assert.match(textOf(notStarted), /Theirs/) }) -test('the Delivery view drops the Bot rail, while Tasks keeps it (#1173)', () => { +test('one board: no tablist, heading says Board, shows both task and delivery cards (#1326)', () => { const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => ({ ok: false, reason: 'no-github' }) } states.length = 0 - const tasksView = expand(KanbanDialog({ roster, actions })) - const tabs = all(tasksView).filter(node => node.props?.role === 'tab') - assert.equal(tabs.map(node => node.props['data-view']).join('|'), 'tasks|delivery') - assert.equal(tabs.find(node => node.props['data-view'] === 'tasks').props['aria-selected'], 'true') - assert.equal(byClass(tasksView, 'bt-board-rail').length, 1) - assert.equal(all(tasksView).some(node => node.props?.['data-view'] === 'delivery' && node.props?.className === 'bt-del-body'), false) - - states.push('delivery') - const deliveryView = expand(KanbanDialog({ roster, actions })) - // The rail is per-Bot; the delivery board is one per repository, so it must not carry one. - assert.equal(byClass(deliveryView, 'bt-board-rail').length, 0) - assert.equal(byClass(deliveryView, 'bt-board-body').length, 0) - assert.equal(all(deliveryView).some(node => node.props?.['data-view'] === 'delivery'), true) - assert.equal(all(deliveryView).find(node => node.props?.role === 'tab' && node.props['data-view'] === 'delivery').props['aria-selected'], 'true') - // No snapshot yet is the honest one-line empty state until the first read lands. - assert.match(textOf(deliveryView), /No snapshot yet/) + const boardView = expand(KanbanDialog({ roster, actions })) + // No tablist — the Tasks/Delivery split is gone (#1326). + const tabs = all(boardView).filter(node => node.props?.role === 'tab') + assert.equal(tabs.length, 0, 'no tabs — one board shows both task and delivery cards') + const tablist = all(boardView).find(node => node.props?.role === 'tablist') + assert.equal(tablist, undefined, 'no tablist element') + // Heading says "Board", not "Tasks" or "Delivery". + const heading = all(boardView).find(node => node.props?.id === 'bt-board-heading') + assert.match(textOf(heading), /Board/) + assert.doesNotMatch(textOf(heading), /Tasks/) + assert.doesNotMatch(textOf(heading), /Delivery/) + // The rail is per-Bot; task cards need it, delivery cards do not. + assert.equal(byClass(boardView, 'bt-board-rail').length, 1) + // With no GitHub token, task cards still render — the board is never empty (#1211, #1265). + assert.equal(all(boardView).some(node => node.props?.className === 'bt-del-body'), true) }) -test('a delivery read with no token leaves the task cards and their tab in place (#1211, #1265)', () => { +test('a delivery read with no token still shows task cards on the one board (#1211, #1265, #1326)', () => { // The two halves meet in one dialog: the delivery board can only say "the GitHub side is not // available" (core/test/delivery-view.test.js holds that sentence), and it must not take the task - // board with it — the cards are the other view, and its tab still opens them. + // board with it — the cards are still rendered on the same board. const failed = { ok: false, state: 'failed', repo: null, repos: [], error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null }, @@ -207,13 +206,14 @@ test('a delivery read with no token leaves the task cards and their tab in place } const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => failed } const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) - // view, scope, dragging, error, draft, then the list the board would have read from the host. + // scope, dragging, error, draft, then the list the board would have read from the host. states.length = 0 - states.push('tasks', { kind: 'bot', botId: 's' }, null, '', '', [mine]) - const tasksView = expand(KanbanDialog({ roster, actions })) - assert.match(textOf(tasksView), /Draft the note/) - assert.equal(byClass(tasksView, 'bt-board-card').length, 1) - assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) + states.push({ kind: 'bot', botId: 's' }, null, '', '', [mine]) + const boardView = expand(KanbanDialog({ roster, actions })) + assert.match(textOf(boardView), /Draft the note/) + assert.equal(byClass(boardView, 'bt-board-card').length, 1) + // No tabs — one board shows both (#1326). + assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) // #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index cb50b3803..d76e5e143 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -35,14 +35,14 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/panes.js` | `PANE_MIN_WIDTH_CSS` (#705): every pane the multi-pane row flexes gets `min-width:0`, the conversation column is matched by its DSH class fragment, and the panel's long values (About text, `4.1M tok · Cache hit …`) wrap with `overflow-wrap:anywhere`, so a value that cannot wrap never widens the panel past the space reserved for it. The same sheet clips horizontal overflow on the document, the conversation column and the app frame (`overflow-x:clip`, #1086), and `watchShellLevel` / `levelShell` put a leftover `scrollLeft` back, so an error toast or dialog cannot leave the window shifted. Tests: `core/test/shell-level.test.js`. | | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. | -| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run; the picker opens on its placeholder, not on the first other chat, so a target is picked before **Copy** can send, #1322) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | +| `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | -| `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | +| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | | `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` — the same key for an issue and the pull request that closes it, which is the identity step 3 hangs a FlyBot record under — and `taskCardKey(id)` is `task:`. `githubCard(row, repo)` and `taskCard(task)` build the model the component draws, `sameCard(a, b)` is the one comparison both sides dedupe by, and a GitHub row with no repository falls back to `github::` rather than imply an identity across repositories. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog is one board, not two tabs (#1326): `UnifiedBoard` keeps `boardRail` on the left and draws task cards and GitHub cards in the same columns through `DeliveryView`, so the rail only picks the task scope — the GitHub side stays repository-wide. A task card on that board is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 92a7a67ed..a0e47ac9d 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,7 +38,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | -| `core/src/host/delivery-board.js` | The delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, laid out by delivery stage. Every request is a `GET`; the token is read host-side (`rt.secretValue`, falling back to none) and never leaves the payload, which carries no credential. `read({ force })` caches per URL with the ETag and revalidates with `If-None-Match`, serving the old snapshot on a 304 and while a refresh runs; there is no timer — the client asks. Issues are placed by label (`agent-working` → working, `agent-ready` → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. A PR card carries the verdict badge of a recent `Verdict:` on its own tip (an old-tip verdict does not count; CI decides) — every comment page is read, so a verdict past the first hundred comments still decides — and an issue is linked to a pull request only when a closing keyword (`close`/`fix`/`resolve` with `#n`) names it, never because a body merely mentions the number (#1189), and the header reports the Asia/Shanghai day, the 7-day window and CI as `counted` of `total`. The payload also carries `metrics.daily` (#1222): the last seven Asia/Shanghai days, oldest first and today last with `partial: true`, each with the issues and pull requests opened, closed and merged and one `{ failed, counted, total }` CI block per day — a cancelled run is left out of the denominator. A day whose list or runs were truncated, or whose CI could not be read, is `null` and never `0`; a week that could not be read makes the whole series `null`, so the view says it has no data instead of drawing seven zeros. The series costs no request: it is assembled from the week and today windows the round already reads. Endpoint `delivery-board`, allowed to any signed-in member because it reads one repository rather than an owner's Bots. #1211: a read never reports a blank board for a problem it can name. The first read answers `{ state: 'loading', since, attempt }` at once and the round runs in the background on the same `inflight`, because a request parked on a whole round is cut by the reverse proxy before it answers. Then `fresh`, `stale` (an older snapshot kept, with `error: { message, endpoint, status, at }` carried into the next read rather than dropped to `null`) and `partial` (a round past `BUILD_BUDGET_MS` — 90 s — lands with the columns it has and the metrics it read). Degradation comes before retry: an unreadable `/actions/runs` makes CI `null` and the row says `unknown`, never `0`; an unreadable check-runs writes `checks-unknown`; unreadable comments leave that card's owner blank; only the two open tables (`pulls`, `issues`) fail a round, because without them there are no columns. A failed round backs off `10 s → 30 s → 2 min` (`BACKOFF_MS`) unless the client forces a read, and a 403 names `retry-after`, else `max(0, x-ratelimit-reset - now)`, and no number at all when neither is present. `probeGitHub()` walks five endpoints and returns status plus the setting to fix; `probe()` and the `delivery-probe` endpoint expose it, and `core/scripts/delivery-probe.mjs` is a thin shell over the same function. Since #1250 the repository is not baked in, and since #1265 step 4 the setting may name several: every read resolves a list through `delivery-repo.js` — `delivery.repos` (a list), or the older `delivery.repo` read as a list of one (nested, dotted or flat, since the host expands no dotted name), then the workspace's `.git/config` `[remote "origin"] url`, then the package's own `repository.url` as a labelled default — and every name keeps its own `source` (`config` / `remote` / `package`), so two repositories never share one source line. The payload carries one view per repository in `repos[]` (each with its own `repo`, `repoSource`, `state`, `columns`, `metrics`, `error` and `retryAt`); with one repository the top-level fields are that repository's own and the screen is unchanged. With several, the body state is the worst of them — a repository that cannot be read is `failed` and leaves the readable ones on a `partial` board, never an empty one — the top-level `columns` is the union (each card still keyed by its repository) and `metrics` is the sum of the additive numbers only while every repository read — one repository that did not answer leaves `metrics: null` so the header prints `—` instead of the readable half — with medians unknown because a median across repositories is not the sum of their medians. One board is kept per repository, so a changed list starts only its new name at `loading`; nothing resolving answers a failed read with `no repository — set delivery.repo` before any board exists, so no request goes to `/repos/null`. Tests: `core/test/delivery-board.test.js`, `core/test/delivery-repo.test.js`. | +| `core/src/host/delivery-board.js` | The delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, laid out by delivery stage. Every request is a `GET`; the token is read host-side (`rt.secretValue`, falling back to none) and never leaves the payload, which carries no credential. `read({ force })` caches per URL with the ETag and revalidates with `If-None-Match`, serving the old snapshot on a 304 and while a refresh runs; there is no timer — the client asks. Issues are placed by workflow stage, not by a fixed five-column list (#1326): `columnOfDelivery` puts an issue with no work label in `not-started`, an `agent-working`/`agent-pr` issue and every open pull request in `in-progress`, and a merge or a close since the day start in `done` — a pull request closed without merging counts, so finished work never sits in no column. The finer stage travels on the card itself (`stage: 'review'` on an open pull request, `stage: 'done-today'` on today's finished work), which is what lets the view filter by review or by done-today without drawing a column for either. A PR card carries the verdict badge of a recent `Verdict:` on its own tip (an old-tip verdict does not count; CI decides) — every comment page is read, so a verdict past the first hundred comments still decides — and an issue is linked to a pull request only when a closing keyword (`close`/`fix`/`resolve` with `#n`) names it, never because a body merely mentions the number (#1189), and the header reports the Asia/Shanghai day, the 7-day window and CI as `counted` of `total`. The payload also carries `metrics.daily` (#1222): the last seven Asia/Shanghai days, oldest first and today last with `partial: true`, each with the issues and pull requests opened, closed and merged and one `{ failed, counted, total }` CI block per day — a cancelled run is left out of the denominator. A day whose list or runs were truncated, or whose CI could not be read, is `null` and never `0`; a week that could not be read makes the whole series `null`, so the view says it has no data instead of drawing seven zeros. The series costs no request: it is assembled from the week and today windows the round already reads. Endpoint `delivery-board`, allowed to any signed-in member because it reads one repository rather than an owner's Bots. #1211: a read never reports a blank board for a problem it can name. The first read answers `{ state: 'loading', since, attempt }` at once and the round runs in the background on the same `inflight`, because a request parked on a whole round is cut by the reverse proxy before it answers. Then `fresh`, `stale` (an older snapshot kept, with `error: { message, endpoint, status, at }` carried into the next read rather than dropped to `null`) and `partial` (a round past `BUILD_BUDGET_MS` — 90 s — lands with the columns it has and the metrics it read). Degradation comes before retry: an unreadable `/actions/runs` makes CI `null` and the row says `unknown`, never `0`; an unreadable check-runs writes `checks-unknown`; unreadable comments leave that card's owner blank; only the two open tables (`pulls`, `issues`) fail a round, because without them there are no columns. A failed round backs off `10 s → 30 s → 2 min` (`BACKOFF_MS`) unless the client forces a read, and a 403 names `retry-after`, else `max(0, x-ratelimit-reset - now)`, and no number at all when neither is present. `probeGitHub()` walks five endpoints and returns status plus the setting to fix; `probe()` and the `delivery-probe` endpoint expose it, and `core/scripts/delivery-probe.mjs` is a thin shell over the same function. Since #1250 the repository is not baked in, and since #1265 step 4 the setting may name several: every read resolves a list through `delivery-repo.js` — `delivery.repos` (a list), or the older `delivery.repo` read as a list of one (nested, dotted or flat, since the host expands no dotted name), then the workspace's `.git/config` `[remote "origin"] url`, then the package's own `repository.url` as a labelled default — and every name keeps its own `source` (`config` / `remote` / `package`), so two repositories never share one source line. The payload carries one view per repository in `repos[]` (each with its own `repo`, `repoSource`, `state`, `columns`, `metrics`, `error` and `retryAt`); with one repository the top-level fields are that repository's own and the screen is unchanged. With several, the body state is the worst of them — a repository that cannot be read is `failed` and leaves the readable ones on a `partial` board, never an empty one — the top-level `columns` is the union (each card still keyed by its repository) and `metrics` is the sum of the additive numbers only while every repository read — one repository that did not answer leaves `metrics: null` so the header prints `—` instead of the readable half — with medians unknown because a median across repositories is not the sum of their medians. One board is kept per repository, so a changed list starts only its new name at `loading`; nothing resolving answers a failed read with `no repository — set delivery.repo` before any board exists, so no request goes to `/repos/null`. Tests: `core/test/delivery-board.test.js`, `core/test/delivery-repo.test.js`. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | From ac038e55b445c70ef334cc4de6d28e11b607b9ac Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 13:07:59 +0800 Subject: [PATCH 2/5] chore(patches): regenerate for the one-board client (#1326) --- patches/ds-bot/0001-core-fixes.patch | 8 +- patches/ds-bot/0008-client-bundle.patch | 4246 +++++++++-------- .../ds-bot/files/src/client/delivery-view.js | 224 +- patches/ds-bot/files/src/client/kanban.js | 51 +- .../ds-bot/files/src/host/delivery-board.js | 34 +- .../ds-bot/files/test/delivery-board.test.js | 68 +- .../ds-bot/files/test/delivery-view.test.js | 76 +- patches/ds-bot/files/test/kanban.test.js | 50 +- 8 files changed, 2621 insertions(+), 2136 deletions(-) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index af94f17ff..f94b8823f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -690,7 +690,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,69 @@ const ZH = { +@@ -41,22 +41,71 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -745,6 +745,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '{from} → {to}': '{from} → {to}', + '+{count} more': '另有 {count} 条', + delegated: '委托转达', ++ All: '全部', ++ Filter: '筛选', + Review: '查看', + 'Attached files': '附件', + Retry: '重试', @@ -1019,7 +1021,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,72 @@ const ZH = { +@@ -360,9 +360,74 @@ const ZH = { 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -1060,7 +1062,9 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Could not comment on {target}: {error}': '无法在 {target} 上留言:{error}', + 'Could not confirm the comment on {target} went out': '无法确认 {target} 上的留言是否发出', + // Bot task board (#1058) ++ Board: '看板', + Tasks: '任务', ++ 'Close board': '关闭看板', + 'Close tasks': '关闭任务', + 'Task board': '任务板', + 'Not started': '未开始', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 77dbe4075..bb1d237dd 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -124,7 +124,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,68 @@ var ZH = { +@@ -78,21 +78,70 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -178,6 +178,8 @@ diff --git a/client.js b/client.js + "{from} → {to}": "{from} → {to}", + "+{count} more": "另有 {count} 条", + delegated: "委托转达", ++ All: "全部", ++ Filter: "筛选", + Review: "查看", + "Attached files": "附件", + Retry: "重试", @@ -452,7 +454,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,71 @@ var ZH = { +@@ -420,8 +420,73 @@ var ZH = { "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -492,7 +494,9 @@ diff --git a/client.js b/client.js + "Could not comment on {target}: {error}": "无法在 {target} 上留言:{error}", + "Could not confirm the comment on {target} went out": "无法确认 {target} 上的留言是否发出", + // Bot task board (#1058) ++ Board: "看板", + Tasks: "任务", ++ "Close board": "关闭看板", + "Close tasks": "关闭任务", + "Task board": "任务板", + "Not started": "未开始", @@ -1862,7 +1866,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,835 +1624,1788 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,7173 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -3576,6 +3580,40 @@ diff --git a/client.js b/client.js -.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} -.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} -.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} +-.bt-states{display:flex;flex-wrap:wrap;gap:4px} +-.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} +-.bt-state:hover{color:var(--bt-ink)} +-.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} +-.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} +-.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} +-body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} +-body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +-.bt-tabs{flex-wrap:wrap} +-.bt-soft:disabled{opacity:.4;cursor:default} +-.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} +-.bt-members{display:flex;flex-direction:column;gap:1px} +-.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} +-.bt-member:hover{background:var(--bt-hover)} +-.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} +-.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} +-.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} +-.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} +-.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} +-.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} +-.bt-add{color:var(--bt-ink-2);margin-top:2px} +-.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} +-.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} +-.bt-motion-seg{width:216px} +-.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} +-.bt-seg button:disabled{opacity:.4;cursor:default} +-.bt-seg button:active:not(:disabled){scale:.96} +-.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} +-.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} +-@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} +-@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} +// src/client/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -3779,7 +3817,92 @@ diff --git a/client.js b/client.js + }, [value]); + return [value || kept, !value && Boolean(kept)]; +} -+ + +-/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ +-.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} +-.bt-mem-row:hover{background:var(--bt-hover)} +-.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} +-.bt-mem-row-copy{flex:1;text-align:left} +-.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} +-.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-mem-scrim{animation:bt-fade .2s ease-out} +-.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +-.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} +-.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} +-.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} +-.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} +-.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} +-.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} +-.bt-mem-head-gap{flex:1} +-.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} +-.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} +-.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} +-.bt-mem-summary{transition:opacity .3s ease} +-.bt-mem-summary[data-stale]{opacity:.55} +-.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} +-.bt-mem-summary h3:first-child{margin-top:0} +-.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} +-.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-summary>:nth-child(2){animation-delay:.03s} +-.bt-mem-summary>:nth-child(3){animation-delay:.06s} +-.bt-mem-summary>:nth-child(4){animation-delay:.09s} +-.bt-mem-summary>:nth-child(5){animation-delay:.12s} +-.bt-mem-summary>:nth-child(6){animation-delay:.15s} +-.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} +-@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} +-.bt-mem-skel{display:flex;flex-direction:column;gap:24px} +-.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} +-.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} +-.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} +-@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} +-.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} +-.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} +-.bt-mem-empty-mark svg{width:22px;height:22px} +-.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} +-.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} +-.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} +-.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} +-.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} +-.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} +-.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} +-.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +-.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} +-.bt-mem-entry[data-busy]{opacity:.4} +-.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +-.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} +-.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +-.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} +-.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} +-.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} +-@media (hover:none){.bt-mem-remove{opacity:1}} +-.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} +-.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} +-.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +-.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} +-.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} +-.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} +-.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} +-.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} +-.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} +-.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} +-.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} +-.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} +-.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} +-.bt-mem-input::placeholder{color:var(--bt-ink-3)} +-.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mem-send:disabled{opacity:.22;cursor:default} +-.bt-mem-send:not(:disabled):active{scale:.92} +-.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} +-@keyframes bt-mem-spin{to{transform:rotate(360deg)}} +-@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} +-@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} +// src/client/styles.js +var SHELL_CSS = ` +body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} @@ -3889,7 +4012,27 @@ diff --git a/client.js b/client.js +.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} +.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} +.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} -+ + +-.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} +-.bt-thumb[data-hidden]{opacity:0} +-[data-morph]{overflow:hidden!important} +-.bt-veil{animation:bt-veil-in .22s ease-out} +-.bt-with-icon{display:inline-flex;align-items:center;gap:4px} +-.bt-with-icon svg{width:14px;height:14px} +-.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} +-.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} +-.bt-create-inline .bt-create-card{max-width:none} +-.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} +-.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} +-.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} +-.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} +-:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} +-:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} +-@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} +.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} +.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -4449,98 +4592,41 @@ diff --git a/client.js b/client.js +.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} +.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} +.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} - .bt-states{display:flex;flex-wrap:wrap;gap:4px} - .bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} - .bt-state:hover{color:var(--bt-ink)} -@@ -2488,2292 +2488,5195 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} - @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} - @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} - --/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ --.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} --.bt-mem-row:hover{background:var(--bt-hover)} --.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} --.bt-mem-row-copy{flex:1;text-align:left} --.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} --.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-mem-scrim{animation:bt-fade .2s ease-out} --.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} --.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} --.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} --.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} --.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} --.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} --.bt-mem-head-gap{flex:1} --.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} --.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} --.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} --.bt-mem-summary{transition:opacity .3s ease} --.bt-mem-summary[data-stale]{opacity:.55} --.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} --.bt-mem-summary h3:first-child{margin-top:0} --.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} --.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-summary>:nth-child(2){animation-delay:.03s} --.bt-mem-summary>:nth-child(3){animation-delay:.06s} --.bt-mem-summary>:nth-child(4){animation-delay:.09s} --.bt-mem-summary>:nth-child(5){animation-delay:.12s} --.bt-mem-summary>:nth-child(6){animation-delay:.15s} --.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} --@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} --.bt-mem-skel{display:flex;flex-direction:column;gap:24px} --.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} --.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} --.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} --@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} --.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} --.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} --.bt-mem-empty-mark svg{width:22px;height:22px} --.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} --.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} --.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} --.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} --.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} --.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} --.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} --.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} --.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} --.bt-mem-entry[data-busy]{opacity:.4} --.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} --.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} --.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} --.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} --.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} --@media (hover:none){.bt-mem-remove{opacity:1}} --.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} --.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} --.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} --.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} --.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} --.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} --.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} --.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} --.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} --.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} --.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-mem-input::placeholder{color:var(--bt-ink-3)} --.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mem-send:disabled{opacity:.22;cursor:default} --.bt-mem-send:not(:disabled):active{scale:.92} --.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} --@keyframes bt-mem-spin{to{transform:rotate(360deg)}} --@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} --@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} ++.bt-states{display:flex;flex-wrap:wrap;gap:4px} ++.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} ++.bt-state:hover{color:var(--bt-ink)} ++.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} ++.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} ++.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} ++body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} ++body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} ++.bt-tabs{flex-wrap:wrap} ++.bt-soft:disabled{opacity:.4;cursor:default} ++.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} ++.bt-members{display:flex;flex-direction:column;gap:1px} ++.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} ++.bt-member:hover{background:var(--bt-hover)} ++.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} ++.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} ++.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} ++.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} ++.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} ++.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} ++.bt-add{color:var(--bt-ink-2);margin-top:2px} ++.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} ++.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} ++.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} ++.bt-motion-seg{width:216px} ++.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} ++.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} ++.bt-seg button:disabled{opacity:.4;cursor:default} ++.bt-seg button:active:not(:disabled){scale:.96} ++.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} ++.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} ++@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} ++@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} ++ +/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ +.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} +.bt-mem-row:hover{background:var(--bt-hover)} @@ -5425,108 +5511,7 @@ diff --git a/client.js b/client.js + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); +} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} --.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} --.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} --.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} --:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} --:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} --@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} --`; --var CONVERSATION_CSS = ` --.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} --.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} --.bt-event>span{padding:0 2px} --.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} --[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.bt-group-replies{display:contents} --/* A Bot chat is flat in every work-details mode, without changing the user's mode -- (it is a DSH-wide setting): step groups lose their header and stay open, and what a -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} --[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} --/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -- without its copy and branch row; a tail with nothing in it takes no room. */ --[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -- the shell's new-session hero: no headline, no workspace or mode picker, and the -- composer stays docked at the bottom. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} --[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} --[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} --[data-chat-running]>span:not([role=status]){display:none!important} --[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} --.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} --.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} ++ +// src/client/secret-card.js +var import_react7 = require("react"); +var MIN_VALUE = 4; @@ -5703,114 +5688,7 @@ diff --git a/client.js b/client.js + 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; - } --@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} --@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} --[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} --[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} --[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} --[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} --[data-composer-placeholder]{font-size:0!important;line-height:0!important} --[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} --/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ --[data-composer-card] button[class*="_add"]{display:none!important} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.9} --.bt-mic:active,.bt-voice:active{scale:.9} --[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} --body[data-bt-room] .bt-voice{display:none!important} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} --[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} --.bt-q{order:1} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} --.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} --.bt-urow{display:flex;justify-content:flex-end} --.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} --.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} --.bt-ububble p{margin:0;line-height:22px} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.bt-earlier .bt-event{margin:4px 0} --.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} --.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} ++} +function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const shown = useCards?.((state) => state[sessionId]) ?? NONE; @@ -6046,22 +5924,86 @@ diff --git a/client.js b/client.js +.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}} `; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; --} --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -- } +-var CONVERSATION_CSS = ` +-.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} +-.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} +-.bt-think summary::-webkit-details-marker{display:none} +-.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} +-.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} +-.bt-event>span{padding:0 2px} +-.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} +-.bt-event button:hover{background:var(--bt-hover)} +-.bt-event button:active{background:var(--bt-active)} +-.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} +-.bt-event-whole>*{flex:none} +-.bt-event-whole>span:first-child{margin-right:2px} +-/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ +-.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} +-[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} +-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} +-.bt-group-replies{display:contents} +-/* A Bot chat is flat in every work-details mode, without changing the user's mode +- (it is a DSH-wide setting): step groups lose their header and stay open, and what a +- mode folds away in a finished turn stays shown. TurnProcessCell also opens that +- fold; these rules cover the first frame before it does. */ +-[data-step-process]>div:first-child{display:none!important} +-[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} +-[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} +-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} +-/* The transcript runs under a floating header pill. */ +-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} +-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} +-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} +-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +-[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} +-/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), +- without its copy and branch row; a tail with nothing in it takes no room. */ +-[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} +-[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} +-/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not +- the shell's new-session hero: no headline, no workspace or mode picker, and the +- composer stays docked at the bottom. */ +-[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +-[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} +-[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} +-[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +-/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ +-[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} +-/* The shell's running status only hosts the activity row (.bt-typing). */ +-[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} +-[data-chat-running]>span:not([role=status]){display:none!important} +-[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} +-.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} +-.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} +-.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} +-.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} +-.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} +-.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} +-.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} +-.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} +-.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} +-.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} +-@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} +-@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} +-@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} +-/* Shimmer: a stepped sweep clipped to the text. */ +-.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} +-@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} +-/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ +-[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} +-@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} +-@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} +-@media (prefers-reduced-motion:reduce){ +-.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} +-.bt-typing[data-exiting]{animation-name:bt-fade-out} +-.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} +-.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} +-[data-bt-enter="new"]{animation:none} +-[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} + +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { @@ -6076,25 +6018,13 @@ diff --git a/client.js b/client.js + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) + }; - }; -- try { -- visit(sheet.cssRules); -- } catch { ++ }; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); - } -- return names; --} --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); ++ } + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; @@ -6125,26 +6055,11 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; - } -- return compiled.get(fragment); -- }); ++ } + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; - } --function 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 facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} @@ -6194,28 +6109,8 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); - }; ++ } ++ }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react8.createElement)( @@ -6246,30 +6141,8 @@ diff --git a/client.js b/client.js + { 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 + ); - } - --// 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); ++} ++ +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; @@ -6347,56 +6220,21 @@ diff --git a/client.js b/client.js + const onDown = (event) => { + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); - }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); ++ }; + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); + } - }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); ++ }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); - return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); ++ return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); - }; -- }, [enabled]); ++ }; + }, [open]); - } --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 MessageTools({ text, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react9.useState)(null); + const [place, setPlace] = (0, import_react9.useState)(null); @@ -6414,10 +6252,7 @@ diff --git a/client.js b/client.js + 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); - }; -- 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) } })); ++ }; + const copy2 = () => { + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); @@ -6456,11 +6291,120 @@ diff --git a/client.js b/client.js + ), document.body) : null + ); } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +-@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} +-@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} +-[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +-[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} +-[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} +-[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +-[data-composer-card]>[class*="_row"]{display:contents!important} +-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} +-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} +-[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} +-[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} +-[data-composer-placeholder]{font-size:0!important;line-height:0!important} +-[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} +-/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ +-[data-composer-card] button[class*="_add"]{display:none!important} +-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-plus:active{scale:.9} +-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} +-.bt-plus[aria-expanded=true] svg{rotate:45deg} +-.bt-plus-menu{min-width:180px} +-[data-composer-card] [class*="_tools"]{gap:0!important} +-[data-composer-card] [class*="_modes"]:empty{display:none!important} +-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} +-[data-composer-card] [class*="_trailing"]{gap:8px} +-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} +-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} +-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} +-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} +-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} +-[data-composer-card] button[class*="_primary"]:disabled{display:none!important} +-[data-composer-dock]{display:none!important} +-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} +-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} +-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} +-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-voice:hover{opacity:.9} +-.bt-mic:active,.bt-voice:active{scale:.9} +-[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} +-body[data-bt-room] .bt-voice{display:none!important} +-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} +-.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} +-[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} +-.bt-q{order:1} +-div:has(> nav [class*="_marks"]){display:none!important} +-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) +- takes its surface and its text from the one --bt-user pair: a skin that repaints +- the host's bubble surface alone would otherwise leave the text unreadable. */ +-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} +-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} +-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ +-[class*="_userRow"] [class*="_actions"]{display:none!important} +-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} +-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} +-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} +-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} +-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} +-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} +-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} +-.bt-q-inline{display:flex;margin:4px 0 6px} +-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} +-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} +-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} +-.bt-q-dock{width:100%;display:flex;justify-content:center} +-.bt-q-dock .bt-q{margin:0 auto 8px} +-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} +-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} +-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-[class*="_toBottomSlot"]>button:active{scale:.9} +-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} +-[class*="_toBottomSlot"]>button svg{display:none} +-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} +-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} +-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} +-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +-@keyframes bt-news-in{from{translate:0 8px;scale:.9}} +-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} +-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} +-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} +-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} +-.bt-news-ico svg{width:16px;height:16px} +-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} +-.bt-news-x svg{width:12px;height:12px} +-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} +-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} +-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} +-.bt-open-new{display:flex;flex-direction:column} +-.bt-open-new .bt-new-sep{margin:18px 0 0} +-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} +-.bt-earlier{display:flex;flex-direction:column;gap:6px} +-.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} +-.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} +-.bt-urow{display:flex;justify-content:flex-end} +-.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} +-.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} +-.bt-ububble p{margin:0;line-height:22px} +-.bt-uimages{display:block;font-size:12px;opacity:.7} +-.bt-earlier .bt-event{margin:4px 0} +-.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} +-.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} +-`; +-function installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; +function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react9.createElement)( @@ -6468,25 +6412,26 @@ diff --git a/client.js b/client.js + { className: "bt-think" }, + (0, import_react9.createElement)("summary", null, (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) - ); ++ ); } --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 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_react9.useMemo)(() => { +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); ++function 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_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]); - } --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 useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; @@ -6535,24 +6480,8 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles - ); - } --function 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 HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -6563,19 +6492,7 @@ diff --git a/client.js b/client.js + children, + closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + ); - } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null ++} +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); + return (0, import_react9.createElement)( @@ -6583,15 +6500,8 @@ 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 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 EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { + return (0, import_react9.createElement)( @@ -6605,9 +6515,7 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) + ) + ); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++ } + const parts = []; + parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { @@ -6633,18 +6541,7 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); - } --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 ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -6664,13 +6561,8 @@ diff --git a/client.js b/client.js + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) - ); - } -- 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)( ++ ); ++ } + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react9.createElement)( + "button", @@ -6678,55 +6570,18 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(BotMark, { bot, size: 22 }) + ); + const name = (0, import_react9.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)); -- }) ++ "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + faceBotLabel(room2, roster, bot) ?? bot.name, + room2?.admin === bot.id ? (0, import_react9.createElement)("span", { className: "bt-group-role" }, t("Admin")) : 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 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" }) ++ ); + return (0, import_react9.createElement)( + "div", + { className: "bt-group-msg" }, + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); - } --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 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 remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6794,42 +6649,22 @@ diff --git a/client.js b/client.js + ), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } --function 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 FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); - } --function ShareIcon() { -- return (0, import_react3.createElement)( ++} +function GlobeMark() { + return (0, import_react9.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" }) ++ "svg", + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); - } --function 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 RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); + const [error, setError] = (0, import_react9.useState)(""); @@ -6848,8 +6683,11 @@ diff --git a/client.js b/client.js + setError(String(reason?.message ?? reason)); + } finally { + setBusy(""); -+ } -+ }; + } + }; +- try { +- visit(sheet.cssRules); +- } catch { + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -6859,15 +6697,7 @@ 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 - ); --}; --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 GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( @@ -6875,33 +6705,8 @@ diff --git a/client.js b/client.js + { className: "bt-event" }, + (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 - ); - } --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 MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -6914,51 +6719,16 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-event" }, + (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) - ); - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } ++ ); ++} +function PartLine({ time }) { + 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 names; + return null; +}; +function EarlierParts({ sessionId, roster, actions }) { @@ -6987,10 +6757,7 @@ diff --git a/client.js b/client.js + load(void 0); + return () => { + alive.current = false; - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ }; + }, [sessionId]); + (0, import_react9.useLayoutEffect)(() => { + const pending = restore.current; @@ -7052,19 +6819,15 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) + ); } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; +-function 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 TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -7079,7 +6842,19 @@ diff --git a/client.js b/client.js + const order = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -+ }); + }); +-} +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); + const peers = useTurnClock((state) => { + if (!relayed) return ""; + const turns = state[sessionId] ?? {}; @@ -7099,30 +6874,7 @@ diff --git a/client.js b/client.js + 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; - } -- 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(); ++ } + if (source.kind === "bot") { + 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; @@ -7134,7 +6886,8 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById[sessionId]; + const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -+ } + } +- for (const name of list) names.add(name); + if (source.role === "report") { + return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + } @@ -7160,36 +6913,27 @@ diff --git a/client.js b/client.js + whole: true + }); } -- }) : 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) -- )); +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); +- }; + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); @@ -7197,7 +6941,44 @@ diff --git a/client.js b/client.js + if (source.kind === "user-question-reply") return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } -+} + } +- +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +- }; +- }, [enabled]); +function NewDivider() { + return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); +} @@ -7218,10 +6999,45 @@ diff --git a/client.js b/client.js + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; -+} + } +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +- }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +function Nothing() { + return null; -+} + } +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +- ); +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([ @@ -7270,20 +7086,12 @@ diff --git a/client.js b/client.js + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); - } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); ++ } + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); -+} + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -7294,61 +7102,37 @@ diff --git a/client.js b/client.js + if (days === 1) return t("Yesterday {time}", { time: clock4 }); + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +- ); +function nextTurnStart(turns, turn) { + let next; + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot -- ); ++ } + return next === void 0 ? void 0 : turns[next].first; } --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")) -- ) -- ); +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -7357,28 +7141,26 @@ diff --git a/client.js b/client.js + const listed = firstTurns.split(",").map(Number); + const earlier = listed.filter((entry) => entry < turn); + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +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 ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; - } -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) ++ } + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; @@ -7406,19 +7188,13 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; +-function 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 Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); +} @@ -7451,10 +7227,7 @@ diff --git a/client.js b/client.js + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; - } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ } + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; @@ -7481,8 +7254,22 @@ diff --git a/client.js b/client.js + 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) }; -+ } -+} + } +- 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 +- ); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); @@ -7529,7 +7316,20 @@ diff --git a/client.js b/client.js + } catch { + replies = []; + } -+ } + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( @@ -7562,7 +7362,9 @@ diff --git a/client.js b/client.js + { 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) }) + ); -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + if (reply.kind === "lookup") { + return (0, import_react9.createElement)( + "div", @@ -7613,19 +7415,14 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); -+ }) -+ ); + }) + ); } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); +- +-// 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" })); +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) { @@ -7637,21 +7434,12 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); - }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); ++ }; + visit(root); + return calls; } --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 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 useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7661,11 +7449,14 @@ diff --git a/client.js b/client.js + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); } --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 PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +- ); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7741,14 +7532,10 @@ diff --git a/client.js b/client.js + return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; - } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); -+} ++ } + } +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; @@ -7765,42 +7552,27 @@ diff --git a/client.js b/client.js + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } - --// 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 ")); +-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" }) +- ); ++ +// 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; -+} + } +-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 })) +- ); + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; @@ -7808,7 +7580,13 @@ diff --git a/client.js b/client.js + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; -+} + } +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +- ); + +// src/client/activity-row.js +var import_react10 = require("react"); @@ -7820,7 +7598,9 @@ diff --git a/client.js b/client.js + if (minutes < 60) return `${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+} + } +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -7834,7 +7614,15 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; -+} + } +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); +function findHost(where) { + if (where !== TAIL_HOST) return document.querySelector(where); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -7849,29 +7637,14 @@ diff --git a/client.js b/client.js + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; }; --var 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 CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +- ); +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react10.useRef)(-1); + const wasSending = (0, import_react10.useRef)(false); @@ -7905,7 +7678,33 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; -+} + } +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +- ); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react10.useState)(null); + (0, import_react10.useEffect)(() => { @@ -7930,7 +7729,42 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; -+} + } +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +function useRowEntrances(replacing) { + const check = (0, import_react10.useRef)(replacing); + check.current = replacing; @@ -7966,8 +7800,11 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); -+ } -+ }; + } + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -7977,7 +7814,20 @@ diff --git a/client.js b/client.js + observer.disconnect(); + }; + }, []); -+} + } +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -8017,11 +7867,29 @@ diff --git a/client.js b/client.js + make(); + return void 0; } -- }); --} --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -8034,7 +7902,22 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; -+ } + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -8078,44 +7961,142 @@ diff --git a/client.js b/client.js + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( - "div", -- { 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)) -- ); ++ "div", + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react10.createElement)( + "span", + { className: "bt-typing-text" }, + (0, import_react10.createElement)( -+ "span", + "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); +- } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); +- } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); +-} +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) -+ ), host); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( + ) +- ); +- } +- return (0, import_react4.createElement)( - "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) +- ); +-} +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; +- } +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ ) ++ ), host); + } +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( +- "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) - ); + +// src/client/dsh-offer.js @@ -8127,6 +8108,196 @@ diff --git a/client.js b/client.js + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); ++ ++// src/client/overlay-hooks.js ++var import_react11 = require("react"); ++var escapeLayers = []; ++var onEscapeKey = (event) => { ++ if (event.key === "Escape") escapeLayers.at(-1)?.current(); ++}; ++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 () => { ++ escapeLayers.splice(escapeLayers.indexOf(ref), 1); ++ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ }; ++ }, []); + } +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); ++function usePaneLeft() { ++ const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); ++ (0, import_react11.useLayoutEffect)(() => { ++ const column = document.querySelector('[class*="_footArea"]')?.parentElement; ++ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); ++ }, []); ++ return paneLeft; + } + +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++// src/client/member-status.js ++var import_react12 = require("react"); ++var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; ++var ROWS_MAX = 4; ++var stepIn = (work, botId, roomId, tool) => { ++ const own = work.bots?.[botId]; ++ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; + }; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } ++var workIn = (room2, work, id) => { ++ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); ++ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; ++ const own = work.bots?.[id]; ++ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; ++}; ++var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); ++function memberWork(room2, work = NO_WORK) { ++ return room2.members.map((id) => { ++ const doing = workIn(room2, work, id); ++ const approvals = askedIn(room2, work, id, doing); ++ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); ++} ++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); ++ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; ++} ++function botGroupWork(botId, work, roster) { ++ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { ++ 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 }] : []; ++ }); ++} ++function summaryText(busy) { ++ const asks = busy.filter((entry) => entry.state === "ask"); ++ const workers = busy.filter((entry) => entry.state === "work"); ++ const names = asks.slice(0, 2).map((entry) => entry.bot.name); ++ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); ++ const first = workers[0]; ++ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; ++ return { waiting, doing }; ++} ++function Face({ entry, size }) { ++ return (0, import_react12.createElement)( ++ "span", ++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, ++ (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); + } +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null ++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; ++function WorkRow({ bot, tool, step, label, at, now, onOpen }) { ++ const text = `${label} · ${workText(tool, step)}`; ++ const elapsed = elapsedSince(now, at); ++ return (0, import_react12.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), ++ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); + } -function SecretCard({ bot, request, roster, actions }) { - const [value, setValue] = (0, import_react5.useState)(""); - const [scope, setScope] = (0, import_react5.useState)("all"); @@ -8174,7 +8345,20 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), - ".", - filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), ++function SummaryLine({ busy, now, onOpen }) { ++ const { waiting, doing } = summaryText(busy); ++ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; ++ return (0, import_react12.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), ++ (0, import_react12.createElement)( ++ "span", ++ { className: "bt-mw-row-text" }, ++ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, ++ waiting && doing ? " · " : null, ++ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null + ), - warn, - error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, - (0, import_react5.createElement)( @@ -8184,25 +8368,8 @@ diff --git a/client.js b/client.js - (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")) - ) -- ); -+ -+// src/client/overlay-hooks.js -+var import_react11 = require("react"); -+var escapeLayers = []; -+var onEscapeKey = (event) => { -+ if (event.key === "Escape") escapeLayers.at(-1)?.current(); -+}; -+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 () => { -+ escapeLayers.splice(escapeLayers.indexOf(ref), 1); -+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; -+ }, []); ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); } -function SettledSecret({ bot, request, roster }) { - const canceled = request.status === "canceled"; @@ -8211,7 +8378,24 @@ diff --git a/client.js b/client.js - "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", ++function MemberDock({ sessionId, useRoster, useWork, actions }) { ++ const roster = useRoster((value) => value); ++ const work = useWork((value) => value); ++ const room2 = roster.roomsById[sessionId]; ++ const bot = room2 ? void 0 : roster.byId[sessionId]; ++ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; ++ const busy = room2 ? busyMembers(room2, work, roster) : []; ++ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; ++ const shown = busy.length > 0 || groups.length > 0; ++ const now = useNow(shown); ++ const ref = (0, import_react12.useRef)(null); ++ useFlowColumn(ref, shown, sessionId); ++ if (!shown) return null; ++ if (room2) { ++ const { rows, rest } = splitBusy(busy); ++ const open = () => actions.showDetails(sessionId); ++ return (0, import_react12.createElement)( + "div", - { className: "bt-q-head" }, - (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), - (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( @@ -8223,39 +8407,10 @@ diff --git a/client.js b/client.js - 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 usePaneLeft() { -+ const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); -+ (0, import_react11.useLayoutEffect)(() => { -+ const column = document.querySelector('[class*="_footArea"]')?.parentElement; -+ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); -+ }, []); -+ return paneLeft; - } +-} -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 }); -+ -+// src/client/member-status.js -+var import_react12 = require("react"); -+var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; -+var ROWS_MAX = 4; -+var stepIn = (work, botId, roomId, tool) => { -+ const own = work.bots?.[botId]; -+ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; -+}; -+var workIn = (room2, work, id) => { -+ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); -+ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; -+ const own = work.bots?.[id]; -+ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; -+}; -+var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); -+function memberWork(room2, work = NO_WORK) { -+ return room2.members.map((id) => { -+ const doing = workIn(room2, work, id); -+ const approvals = askedIn(room2, work, id, doing); -+ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; -+ }); - } +-} -function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { - const bot = roster.byId[sessionId]; - if (!bot) return null; @@ -8263,12 +8418,13 @@ diff --git a/client.js b/client.js - 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") }))); -- } ++ { ref, className: "bt-mw-dock-stack" }, ++ rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), ++ rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null ++ ); + } - return null; -+function 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 SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { - const roster = useRoster((value) => value); - const shown = useCards?.((state) => state[sessionId]) ?? NONE; @@ -8279,10 +8435,16 @@ diff --git a/client.js b/client.js - const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); - if (waiting.length === 0) return null; - return (0, import_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 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); -+ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; ++ 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, ++ tool: item.tool, ++ step: item.step, ++ label: t("in {name}", { name: item.room.name }), ++ at: item.since, ++ now, ++ onOpen: () => actions.openSession(item.room.id) ++ }))); } -function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); @@ -8316,12 +8478,22 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) - ) - ); -+function botGroupWork(botId, work, roster) { -+ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { -+ 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 }] : []; -+ }); ++function useFlowColumn(ref, active, key) { ++ (0, import_react12.useLayoutEffect)(() => { ++ const node = ref.current; ++ const flow = document.querySelector("[data-chat-flow]"); ++ if (!active || !node || !flow) return void 0; ++ const place = () => { ++ node.style.marginInlineStart = "0px"; ++ const column = flow.getBoundingClientRect(); ++ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; ++ node.style.width = `${Math.round(column.width)}px`; ++ }; ++ place(); ++ const observer = new ResizeObserver(place); ++ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); ++ return () => observer.disconnect(); ++ }, [active, key]); } -function ValueEditor({ secret, onSave }) { - const [value, setValue] = (0, import_react5.useState)(""); @@ -8339,34 +8511,46 @@ diff --git a/client.js b/client.js - const edit = (next) => { - setValue(next); - setReplaced(false); -- }; ++function useNow(active) { ++ const [now, setNow] = (0, import_react12.useState)(Date.now); ++ (0, import_react12.useEffect)(() => { ++ if (!active) return void 0; ++ const timer = setInterval(() => setNow(Date.now()), 3e4); ++ return () => clearInterval(timer); ++ }, [active]); ++ return now; ++} ++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"]] : [] ++]; ++function ApprovalCard({ approval, bot, actions }) { ++ const [busy, setBusy] = (0, import_react12.useState)(false); ++ const answer = (choice) => { ++ setBusy(true); ++ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); + }; - return (0, import_react5.createElement)( -- "div", ++ return (0, import_react12.createElement)( + "div", - { className: "bt-secret-edit-block" }, - (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), - (0, import_react5.createElement)( -- "div", ++ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, ++ (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), ++ approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, ++ approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, ++ approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, ++ approval.answerable ? (0, import_react12.createElement)( + "div", - { className: "bt-secret-inline" }, - (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) - ), - value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+function summaryText(busy) { -+ const asks = busy.filter((entry) => entry.state === "ask"); -+ const workers = busy.filter((entry) => entry.state === "work"); -+ const names = asks.slice(0, 2).map((entry) => entry.bot.name); -+ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); -+ const first = workers[0]; -+ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; -+ return { waiting, doing }; -+} -+function Face({ entry, size }) { -+ return (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, -+ (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) - ); - } +- ); +-} -function SecretRow({ secret, roster, actions, run, open, onToggle }) { - const [confirm, setConfirm] = (0, import_react5.useState)(false); - (0, import_react5.useEffect)(() => { @@ -8392,34 +8576,14 @@ diff --git a/client.js b/client.js - { 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 }))) - ) -+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -+function WorkRow({ bot, tool, step, label, at, now, onOpen }) { -+ const text = `${label} · ${workText(tool, step)}`; -+ const elapsed = elapsedSince(now, at); -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), -+ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); -+} -+function SummaryLine({ busy, now, onOpen }) { -+ const { waiting, doing } = summaryText(busy); -+ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), -+ (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-row-text" }, -+ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, -+ waiting && doing ? " · " : null, -+ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null - ), +- ), - open ? (0, import_react5.createElement)( -- "div", ++ { className: "bt-mw-actions" }, ++ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), ++ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), ++ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) ++ ) : (0, import_react12.createElement)( + "div", - { className: "bt-secret-edit" }, - (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), - (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), @@ -8433,7 +8597,9 @@ diff --git a/client.js b/client.js - ] : (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 -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ++ { className: "bt-mw-actions" }, ++ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) ++ ) ); } -function AddSecret({ actions, run, onDone }) { @@ -8449,76 +8615,20 @@ diff --git a/client.js b/client.js - onDone(); - } - }); -+function MemberDock({ sessionId, useRoster, useWork, actions }) { -+ const roster = useRoster((value) => value); -+ const work = useWork((value) => value); -+ const room2 = roster.roomsById[sessionId]; -+ const bot = room2 ? void 0 : roster.byId[sessionId]; -+ const ownChat = bot !== void 0 && work.bots?.[bot.id] !== void 0 && work.bots[bot.id].roomId === void 0; -+ const busy = room2 ? busyMembers(room2, work, roster) : []; -+ const groups = bot !== void 0 && !ownChat ? botGroupWork(bot.id, work, roster) : []; -+ const shown = busy.length > 0 || groups.length > 0; -+ const now = useNow(shown); -+ const ref = (0, import_react12.useRef)(null); -+ useFlowColumn(ref, shown, sessionId); -+ if (!shown) return null; -+ if (room2) { -+ const { rows, rest } = splitBusy(busy); -+ const open = () => actions.showDetails(sessionId); ++function MemberWork({ entry, bot, room: room2, actions, now }) { ++ const [stopping, setStopping] = (0, import_react12.useState)(false); ++ if (entry.state === "ask") { + return (0, import_react12.createElement)( + "div", -+ { ref, className: "bt-mw-dock-stack" }, -+ rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), -+ rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null ++ { className: "bt-mw-member" }, ++ (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 })) + ); + } -+ 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, -+ tool: item.tool, -+ step: item.step, -+ label: t("in {name}", { name: item.room.name }), -+ at: item.since, -+ now, -+ onOpen: () => actions.openSession(item.room.id) -+ }))); -+} -+function useFlowColumn(ref, active, key) { -+ (0, import_react12.useLayoutEffect)(() => { -+ const node = ref.current; -+ const flow = document.querySelector("[data-chat-flow]"); -+ if (!active || !node || !flow) return void 0; -+ const place = () => { -+ node.style.marginInlineStart = "0px"; -+ const column = flow.getBoundingClientRect(); -+ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; -+ node.style.width = `${Math.round(column.width)}px`; -+ }; -+ place(); -+ const observer = new ResizeObserver(place); -+ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); -+ return () => observer.disconnect(); -+ }, [active, key]); -+} -+function useNow(active) { -+ const [now, setNow] = (0, import_react12.useState)(Date.now); -+ (0, import_react12.useEffect)(() => { -+ if (!active) return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 3e4); -+ return () => clearInterval(timer); -+ }, [active]); -+ return now; -+} -+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"]] : [] -+]; -+function ApprovalCard({ approval, bot, actions }) { -+ const [busy, setBusy] = (0, import_react12.useState)(false); -+ const answer = (choice) => { -+ setBusy(true); -+ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); ++ if (entry.state !== "work") return null; ++ const stop = () => { ++ setStopping(true); ++ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); }; - return (0, import_react5.createElement)( + return (0, import_react12.createElement)( @@ -8528,25 +8638,16 @@ diff --git a/client.js b/client.js - (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)( -+ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, -+ (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), -+ approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, -+ approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, -+ approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, -+ approval.answerable ? (0, import_react12.createElement)( ++ { className: "bt-mw-member" }, ++ (0, import_react12.createElement)( "div", - { className: "bt-secret-row-actions" }, - (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -+ { className: "bt-mw-actions" }, -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), -+ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) -+ ) : (0, import_react12.createElement)( -+ "div", -+ { className: "bt-mw-actions" }, -+ (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) ++ { className: "bt-mw-state bt-mw-work" }, ++ (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), ++ entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null ) ); } @@ -8643,7 +8744,12 @@ diff --git a/client.js b/client.js - const members = steps.length ? steps : [current]; - if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; - return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); --} ++function 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 }; + } -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(); @@ -8660,21 +8766,7 @@ diff --git a/client.js b/client.js - event.preventDefault(); - event.stopPropagation(); - actions.openSession(target); -+function MemberWork({ entry, bot, room: room2, actions, now }) { -+ const [stopping, setStopping] = (0, import_react12.useState)(false); -+ if (entry.state === "ask") { -+ return (0, import_react12.createElement)( -+ "div", -+ { className: "bt-mw-member" }, -+ (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); -+ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; +- }; - const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; - return (0, import_react6.createElement)( - "div", @@ -8707,39 +8799,7 @@ diff --git a/client.js b/client.js - }, emoji))) : null - ); - }) -+ return (0, import_react12.createElement)( -+ "div", -+ { className: "bt-mw-member" }, -+ (0, import_react12.createElement)( -+ "div", -+ { className: "bt-mw-state bt-mw-work" }, -+ (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), -+ entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null -+ ) - ); - } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -+function 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 }; -+} +- ); +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} @@ -8828,39 +8888,21 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); - }; -- }, [open]); ++ }; + }, []); + return tasks; } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { +-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(); -- return; -- } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -8987,16 +9029,11 @@ diff --git a/client.js b/client.js + at: entry.since, + since: entry.since, + title: `${workPhrase(entry.tool)}…`, -+ place: placeOf(roster, roomId), -+ chat: roomId ?? bot.id, -+ ...people(roster, bot.id) - }); - }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ place: placeOf(roster, roomId), ++ chat: roomId ?? bot.id, ++ ...people(roster, bot.id) ++ }); ++ }; + 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(); @@ -9036,7 +9073,11 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) -+ }; + }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); @@ -9118,31 +9159,7 @@ diff --git a/client.js b/client.js + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null ++ }; + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-result" }, @@ -9161,21 +9178,34 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); -+ } + } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); + }) : 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; + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) - ); ++ ); } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +-function 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(); +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react13.useState)(0); + const question = card.kind === "question"; @@ -9191,11 +9221,26 @@ diff --git a/client.js b/client.js + const selected = multi ? slot.picked.filter((label) => label !== "") : []; + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); -+ return; -+ } + return; + } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); + if (custom === "") return; + onAnswer(card, custom); -+ }; + }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); +- }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); + const choose = (label) => { + if (busy) return; + if (!multi) { @@ -9204,7 +9249,31 @@ diff --git a/client.js b/client.js + } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); -+ }; + }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-answer" }, @@ -9236,6 +9305,35 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ); } +-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 InboxRow({ wide, useRoster, useWork, useUi, actions }) { ++ const roster = useRoster((value) => value); ++ const work = useWork ? useWork((value) => value) : {}; ++ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; ++ const tasks = useTasks(actions); ++ const count = needsCount(roster, work, tasks); ++ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); ++ return (0, import_react13.createElement)( ++ "button", ++ { ++ type: "button", ++ className: "bt-row bt-inbox-row", ++ "aria-label": label, ++ title: wide ? void 0 : label, ++ "aria-haspopup": "dialog", ++ "aria-expanded": open, ++ onClick: () => actions.openInbox() ++ }, ++ (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), ++ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, ++ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null + ); + } -function useFileMentions({ node, openFile, fileMentions, useTurnData }) { - const tail = useTurnData?.("turn-tail"); - const location2 = node.location; @@ -9245,10 +9343,12 @@ diff --git a/client.js b/client.js - if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; - return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; - }, [fileMentions, openFile, seq, tail, turn]); --} ++function Status({ status }) { ++ const { label, tone, Icon } = STATUSES[status]; ++ return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); + } -function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -+function InboxRow({ wide, useRoster, useWork, useUi, actions }) { - const roster = useRoster((value) => value); +- const roster = useRoster((value) => value); - const data = node.data; - const steps = useTurnSteps(String(data.turn)); - const contexts = useTurnContext(String(data.turn)); @@ -9280,64 +9380,6 @@ diff --git a/client.js b/client.js - }), - reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, - texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -+ const work = useWork ? useWork((value) => value) : {}; -+ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; -+ const tasks = useTasks(actions); -+ const count = needsCount(roster, work, tasks); -+ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); -+ return (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row bt-inbox-row", -+ "aria-label": label, -+ title: wide ? void 0 : label, -+ "aria-haspopup": "dialog", -+ "aria-expanded": open, -+ onClick: () => actions.openInbox() -+ }, -+ (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), -+ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, -+ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); - } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); -- } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); -- }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); -+function Status({ status }) { -+ const { label, tone, Icon } = STATUSES[status]; -+ return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); @@ -9355,16 +9397,11 @@ diff --git a/client.js b/client.js + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react13.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react13.createElement)( - "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react13.createElement)( + "span", @@ -9398,8 +9435,7 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react13.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -9410,13 +9446,9 @@ diff --git a/client.js b/client.js + ) ); } --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 EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +function useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9424,16 +9456,7 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ms]); + return now; - } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( ++} +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9471,13 +9494,18 @@ diff --git a/client.js b/client.js + actions.openSession(card.chat); + }; + return (0, import_react13.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ "div", + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react13.createElement)( + "div", @@ -9511,18 +9539,11 @@ diff --git a/client.js b/client.js + onOpen: open + }))) + )) -+ ) + ) +- ); + ) - ); - } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); --} --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; ++ ); ++} +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9585,27 +9606,7 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); ++}; +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; +} @@ -9674,42 +9675,7 @@ diff --git a/client.js b/client.js + linked: row.linked ?? null, + sha: row.sha ?? null, + url: row.url ?? null - }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -- return () => { -- alive.current = false; -- }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (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 ++ }; +} +function taskCard(task) { + const key = taskCardKey(task?.id); @@ -9801,36 +9767,7 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null - ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ ), + (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -9841,33 +9778,8 @@ diff --git a/client.js b/client.js + card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, + footer, + actions - ); - } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++ ); ++} + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -9897,7 +9809,17 @@ diff --git a/client.js b/client.js + const denominator = Number.isFinite(counted) && counted > 0 ? counted : Number.isFinite(total) && total > 0 ? total : null; + if (!Number.isFinite(failed) || denominator === null) return null; + return Math.min(1, Math.max(0, failed / denominator)); -+ } + } +- 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 +- )); + const value = day[key]; + return Number.isFinite(value) ? value : null; +} @@ -9925,122 +9847,38 @@ diff --git a/client.js b/client.js + const known = values.filter((value) => value !== null); + const top = known.length ? Math.max(...known, 0) : 0; + const span = top > 0 ? top : 1; -+ const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0; -+ const dots = days.map((day, i) => { -+ const value = values[i]; -+ const last = i === days.length - 1; -+ const state = value === null ? "missing" : last && day.partial === true ? "partial" : "full"; -+ return { -+ index: i, -+ x: round(PAD + step * i), -+ y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), -+ today: last, -+ // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. -+ hollow: state === "partial", -+ partial: state === "partial", -+ state, -+ title: pointTitle(day, key), -+ value -+ }; - }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -+ const segments = []; -+ let run = null; -+ for (const dot of dots) { -+ if (dot.y === null) { -+ if (run) { -+ segments.push(run); -+ run = null; - } -- 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")); -+ continue; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } --} --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 TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; --} --function Nothing() { -- return 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 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; ++ const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0; ++ const dots = days.map((day, i) => { ++ const value = values[i]; ++ const last = i === days.length - 1; ++ const state = value === null ? "missing" : last && day.partial === true ? "partial" : "full"; ++ return { ++ index: i, ++ x: round(PAD + step * i), ++ y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), ++ today: last, ++ // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. ++ hollow: state === "partial", ++ partial: state === "partial", ++ state, ++ title: pointTitle(day, key), ++ value ++ }; + }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ const segments = []; ++ let run = null; ++ for (const dot of dots) { ++ if (dot.y === null) { ++ if (run) { ++ segments.push(run); ++ run = null; ++ } ++ continue; ++ } + if (!run) run = { dots: [] }; + run.dots.push(dot); - } -- return next === void 0 ? void 0 : turns[next].first; ++ } + if (run) segments.push(run); + return { + width, @@ -10055,14 +9893,22 @@ diff --git a/client.js b/client.js + })) + }; } --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 GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +- "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10095,36 +9941,35 @@ diff --git a/client.js b/client.js + cy: dot.y, + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) -+ ); + ); } --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; +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); + const fewest = short.length ? Math.min(...short) : 0; + const head = (0, import_react15.createElement)( -+ "div", + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null + { className: "bt-del-dm-head", "data-flybot": "delivery-metrics-head" }, + (0, import_react15.createElement)("span", { className: "bt-del-dm-label" }, ""), + (0, import_react15.createElement)("span", { className: "bt-del-dm-chart-head" }, ""), + (0, import_react15.createElement)("span", { className: "bt-del-dm-value" }, t("Today")), + (0, import_react15.createElement)("span", { className: "bt-del-dm-value bt-del-dm-week" }, t("7 days")) -+ ); + ); +-} +-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)( + const body = rows.map((row, index) => { + const key = CHART_KEYS.includes(row?.key) ? row.key : null; + const chart = days && key ? (0, import_react15.createElement)(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) : null; @@ -10138,15 +9983,11 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") + ]; + return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); - }); -- 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 ++ }); + return (0, import_react15.createElement)( -+ "div", + "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-del-dm", "data-flybot": "delivery-metrics" }, + head, + body, @@ -10154,8 +9995,8 @@ diff --git a/client.js b/client.js + days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null ); } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +var DELIVERY_CHART_CSS = ` +.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} @@ -10178,105 +10019,279 @@ diff --git a/client.js b/client.js + .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} + .bt-del-dm-value{min-width:0} } --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"] -+`; -+ -+// src/client/delivery-view.js -+var import_react16 = require("react"); -+var POLL_MS3 = 6e4; -+var LOADING_POLL_MS = 2e3; -+var RETRY_FLOOR_MS = 1e3; -+var TIMER_MAX_MS = 2 ** 31 - 1; -+function pollDelay(state, payload = null, now = Date.now()) { -+ if (state === "loading" || state === "idle") return LOADING_POLL_MS; -+ const at = Number(payload?.retryAt); -+ if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { -+ return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); -+ } -+ return POLL_MS3; -+} -+var DELIVERY_COLUMNS = [ -+ { id: "raw", label: "Original issue" }, -+ { id: "ready", label: "Ready to start" }, -+ { id: "working", label: "In progress" }, -+ { id: "review", label: "In review" }, -+ { id: "done-today", label: "Done today" } - ]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; ++`; ++ ++// src/client/delivery-view.js ++var import_react16 = require("react"); ++var POLL_MS3 = 6e4; ++var LOADING_POLL_MS = 2e3; ++var RETRY_FLOOR_MS = 1e3; ++var TIMER_MAX_MS = 2 ** 31 - 1; ++function pollDelay(state, payload = null, now = Date.now()) { ++ if (state === "loading" || state === "idle") return LOADING_POLL_MS; ++ const at = Number(payload?.retryAt); ++ if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { ++ return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); + } +- return null; ++ return POLL_MS3; ++} ++var DELIVERY_COLUMNS = [ ++ { id: "not-started", label: "Not started" }, ++ { id: "in-progress", label: "In progress" }, ++ { id: "done", label: "Done" } ++]; ++function stagesOf(view) { ++ const columns = view?.columns; ++ if (columns === null || typeof columns !== "object") return DELIVERY_COLUMNS; ++ const present = Object.keys(columns); ++ if (present.length === 0) return DELIVERY_COLUMNS; ++ const known = DELIVERY_COLUMNS.filter((column) => present.includes(column.id)); ++ const unlabelled = present.filter((id) => !DELIVERY_COLUMNS.some((column) => column.id === id)).map((id) => ({ id, label: id })); ++ return [...known, ...unlabelled]; ++} ++var BADGES = { ++ inc: "INC · rework", ++ "pass-enqueue": "PASS · waiting for enqueue", ++ "pass-merge": "PASS · waiting for merge", ++ "ci-red": "CI red", ++ "ci-running": "CI running", ++ "no-verdict": "No verdict", ++ // The checks could not be read: that is not "no verdict", and saying so blamed the pull request ++ // for a permission the instance is missing (#1211). ++ "checks-unknown": "Checks unknown" + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +- }); ++var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; ++function taskColumnOf(task) { ++ const col = columnOf(task); ++ if (col === "not-started") return "not-started"; ++ if (col === "in-progress") return "in-progress"; ++ return "done"; ++} ++var priorityLabel = (priority) => t(priority === "urgent" ? "Highest" : priority === "high" ? "High" : "Low"); ++var taskShortId = (id) => `#${String(id ?? "").replace("task-", "").slice(-6)}`; ++function taskCardModel(task, roster) { ++ return { ++ source: "task", ++ key: `task:${task?.id}`, ++ title: task?.title ?? "", ++ owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, ++ ownerKind: "bot" + }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; +- }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (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); ++} ++var dropProps = (column, { dragging, onMove, onDrag }) => ({ ++ onDragOver: (event) => { ++ if (!dragging) return; ++ event.preventDefault(); ++ }, ++ onDrop: (event) => { ++ event.preventDefault(); ++ const id = event.dataTransfer?.getData("text/plain") ?? ""; ++ if (id) onMove?.(id, column); ++ onDrag?.(null); + } +- return (0, import_react6.createElement)( ++}); ++var taskCardNode = (task, { roster, onMove, onDrag }) => (0, import_react16.createElement)(BoardCard, { ++ key: task.id, ++ card: taskCardModel(task, roster), ++ skin: TASK_SKIN, ++ attrs: { "data-task": task.id }, ++ idText: taskShortId(task.id), ++ topExtra: task.priority && task.priority !== "normal" ? (0, import_react16.createElement)("span", { className: "bt-board-priority", "data-priority": task.priority }, priorityLabel(task.priority)) : null, ++ draggable: Boolean(onMove), ++ dragValue: task.id, ++ onDragStart: () => onDrag?.(task.id), ++ onDragEnd: () => onDrag?.(null) ++}); ++function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { ++ const columns = { "not-started": [], "in-progress": [], "done": [] }; ++ for (const task of tasks ?? []) { ++ const col = taskColumnOf(task); ++ if (columns[col]) columns[col].push(task); ++ } ++ const columnTitles = { ++ "not-started": t("Not started"), ++ "in-progress": t("In progress"), ++ "done": t("Done") ++ }; ++ return (0, import_react16.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 }) ++ { className: "bt-board-columns" }, ++ ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( ++ "section", ++ { ++ key: column, ++ className: "bt-board-column", ++ "data-column": column, ++ "aria-label": columnTitles[column], ++ ...dropProps(column, { dragging, onMove, onDrag }) ++ }, ++ (0, import_react16.createElement)( ++ "h3", ++ { className: "bt-board-column-head" }, ++ (0, import_react16.createElement)("span", null, columnTitles[column]), ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) ++ ), ++ columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => taskCardNode(task, { roster, onMove, onDrag })) ++ )) + ); + } +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); - } -- 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" }; +- 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")); - } -- 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 stagesOf(view) { -+ const columns = view?.columns; -+ if (columns === null || typeof columns !== "object") return DELIVERY_COLUMNS; -+ const present = Object.keys(columns); -+ if (present.length === 0) return DELIVERY_COLUMNS; -+ const known = DELIVERY_COLUMNS.filter((column) => present.includes(column.id)); -+ const unlabelled = present.filter((id) => !DELIVERY_COLUMNS.some((column) => column.id === id)).map((id) => ({ id, label: id })); -+ return [...known, ...unlabelled]; -+} -+var BADGES = { -+ inc: "INC · rework", -+ "pass-enqueue": "PASS · waiting for enqueue", -+ "pass-merge": "PASS · waiting for merge", -+ "ci-red": "CI red", -+ "ci-running": "CI running", -+ "no-verdict": "No verdict", -+ // The checks could not be read: that is not "no verdict", and saying so blamed the pull request -+ // for a permission the instance is missing (#1211). -+ "checks-unknown": "Checks unknown" -+}; -+var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; +- 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 duration(ms) { + if (!Number.isFinite(ms) || ms < 0) return "—"; + const hours = ms / 36e5; @@ -10304,7 +10319,26 @@ diff --git a/client.js b/client.js + if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); + if (kind === "rate-limit") { + return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); -+ } + } +-} +-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 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; + if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); + if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); + if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); @@ -10312,7 +10346,7 @@ diff --git a/client.js b/client.js + if (scope === "pulls" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Pull requests: Read."); + if (scope === "repo" || kind === "not-found" || kind === "forbidden" || kind === "auth") { + return t("Check the repository name, and that the token can see {repo}.", { repo: target }); - } ++ } + if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); + return t("The next attempt happens while this page is open; the reason above names what failed."); +} @@ -10354,7 +10388,11 @@ diff --git a/client.js b/client.js + if (row.today !== void 0) return { key: row.key, label: row.label, today: row.today, week: row.week }; + const format = row.format ?? count; + return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) }; -+ }); + }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +-} +-function Nothing() { +- return null; + return { + dayStart: snapshot?.dayStart ?? null, + rows, @@ -10370,9 +10408,16 @@ diff --git a/client.js b/client.js + open: snapshot?.open ?? {} + }; } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +-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 createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { + let stopped = false; + let timer = null; @@ -10432,61 +10477,29 @@ diff --git a/client.js b/client.js + } + }; } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); +-function 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 repoSourceText(source) { + if (source === "config") return t("from config"); + if (source === "remote") return t("from remote"); + if (source === "package") return t("from the package (default)"); + return null; - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); ++} +function useDeliveryBoard(actions) { + const [view, setView] = (0, import_react16.useState)({ state: "idle" }); + const [retry, setRetry] = (0, import_react16.useState)(() => () => { + }); + (0, import_react16.useEffect)(() => { + let current = { state: "idle" }; -+ const poller = createPoller({ -+ load: (force) => typeof actions?.deliveryBoard === "function" ? actions.deliveryBoard(force ? { force: true } : {}) : Promise.resolve(null), -+ onView: (next) => { -+ current = next; -+ setView(next); - } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); -- return () => { -- stop = true; -- clearTimeout(timer); -- }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; -- } ++ const poller = createPoller({ ++ load: (force) => typeof actions?.deliveryBoard === "function" ? actions.deliveryBoard(force ? { force: true } : {}) : Promise.resolve(null), ++ onView: (next) => { ++ current = next; ++ setView(next); ++ } + }); + setRetry(() => () => poller.retry(current)); + poller.start(); @@ -10504,14 +10517,18 @@ diff --git a/client.js b/client.js + const where = report.repo === null && row.repo ? `${row.repo} ` : ""; + lines.push(`${where}${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( +- return next === void 0 ? void 0 : turns[next].first; + if (report.error) lines.push(String(report.error)); + return lines.join("\n"); -+} + } +-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 Diagnose({ onProbe }) { + const [report, setReport] = (0, import_react16.useState)(null); + const [busy, setBusy] = (0, import_react16.useState)(false); @@ -10543,7 +10560,30 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setReport(null) }, t("Close")) + ) : null + ); -+} + } +-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 DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { + const payload = view ?? snapshot; + if (!payload) return null; @@ -10598,83 +10638,124 @@ diff --git a/client.js b/client.js + onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, + onProbe ? (0, import_react16.createElement)(Diagnose, { onProbe }) : null + ) + ); + } +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); ++var BOARD_FILTERS = ["all", "review", "done-today"]; ++function BoardFilters({ filter, onFilter }) { ++ return (0, import_react16.createElement)( ++ "div", ++ { className: "bt-del-filters", role: "group", "aria-label": t("Filter") }, ++ BOARD_FILTERS.map((id) => (0, import_react16.createElement)("button", { ++ key: id, ++ type: "button", ++ className: "bt-soft bt-soft-sm", ++ "data-filter": id, ++ "aria-pressed": filter === id ? "true" : "false", ++ onClick: () => onFilter(id) ++ }, id === "all" ? t("All") : id === "review" ? t("Review") : t("Done today"))) + ); -+} -+function DeliveryColumns({ view, repo = null, placed = null }) { + } +-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) }; ++var stageOf = (card, column) => card?.stage ?? column; ++var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; ++function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = view?.columns ?? {}; + const drawn = placed === null || placed === void 0 ? null : new Set(placed); -+ const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)); ++ const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); ++ const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; ++ for (const task of filter === "all" ? tasks ?? [] : []) { ++ const col = taskColumnOf(task); ++ if (tasksByColumn[col]) tasksByColumn[col].push(task); + } ++ const allCards = (id) => { ++ const delivery = deliveryCards(id); ++ const taskList = tasksByColumn[id] ?? []; ++ return [...taskList, ...delivery]; ++ }; ++ const taskIds = new Set((tasks ?? []).map((task) => task?.id)); ++ const isTask = (item) => taskIds.has(item?.id); + return (0, import_react16.createElement)( - "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); -- } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -- } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( ++ "div", + { className: "bt-del-columns" }, + stagesOf(view).map((column) => (0, import_react16.createElement)( + "section", -+ { key: column.id, className: "bt-del-column", "data-column": column.id }, ++ { ++ key: column.id, ++ className: "bt-del-column", ++ "data-column": column.id, ++ ...dropProps(column.id, { dragging, onMove, onDrag }) ++ }, + (0, import_react16.createElement)( + "h3", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, t(column.label)), -+ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards(column.id).length)) ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) + ), -+ cards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards(column.id).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) ++ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? taskCardNode(item, { roster, onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) + )) + ); -+} -+function DeliveryWorkflowSections({ sections, repo = null }) { + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { + const list = Array.isArray(sections) ? sections : []; + if (list.length === 0) return null; + return (0, import_react16.createElement)("div", { className: "bt-del-workflows" }, list.map((section) => (0, import_react16.createElement)( @@ -10690,27 +10771,37 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("span", null, section.name), + (0, import_react16.createElement)("span", { className: "bt-wf-path" }, `workflows/${section.id}.json`) + ), -+ (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => (0, import_react16.createElement)( -+ "section", -+ { -+ key: column.id, -+ className: "bt-del-column", -+ "data-column": column.id, -+ "data-workflow": section.id -+ }, -+ (0, import_react16.createElement)( -+ "h4", -+ { className: "bt-board-column-head" }, -+ (0, import_react16.createElement)("span", null, column.label), -+ (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), -+ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(column.cards.length)) -+ ), -+ column.cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : column.cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) -+ ))), ++ (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => { ++ const cards = column.cards.filter((card) => matchesFilter(card, column.id, filter)); ++ return (0, import_react16.createElement)( ++ "section", ++ { ++ key: column.id, ++ className: "bt-del-column", ++ "data-column": column.id, ++ "data-workflow": section.id ++ }, ++ (0, import_react16.createElement)( ++ "h4", ++ { className: "bt-board-column-head" }, ++ (0, import_react16.createElement)("span", null, column.label), ++ (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards.length)) ++ ), ++ cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) ++ ); ++ })), + (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null + ))); -+} -+function DeliveryRepoBlock({ entry }) { + } +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); ++function DeliveryRepoBlock({ entry, filter = "all" }) { + const state = entry?.state ?? "fresh"; + const source = repoSourceText(entry?.repoSource); + return (0, import_react16.createElement)( @@ -10727,10 +10818,41 @@ diff --git a/client.js b/client.js + { className: "bt-del-block-state", role: "status" }, + state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), + entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" -+ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed }) ++ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter }) + ); -+} -+function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { + } +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +- if (done) return void 0; +- let stop = false; +- let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +- }; +- void tick(); +- return () => { +- stop = true; +- clearTimeout(timer); +- }; +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; +- } ++function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { + const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); + const state = payload.state ?? "fresh"; + const retryAt = Number(payload.retryAt); @@ -10743,18 +10865,84 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ticking, retryAt]); + const countdown = retryCountdown(payload.retryAt, now); ++ const [filter, setFilter] = (0, import_react16.useState)("all"); + const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; ++ const hasTasks = Array.isArray(tasks) && tasks.length > 0; + if (state === "idle") { + return (0, import_react16.createElement)( + "div", -+ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, ++ { className: "bt-del-body", "data-view": "board", "data-state": state }, ++ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), ++ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, + message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) + ); -+ } + } +- 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 (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)( + if (state === "loading") { + return (0, import_react16.createElement)( + "div", -+ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, ++ { className: "bt-del-body", "data-view": "board", "data-state": state }, ++ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), ++ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, + (0, import_react16.createElement)("p", { className: "bt-del-loading", role: "status" }, loadingText(payload.elapsedMs)), + (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("The first round reads the repository once; the columns appear as soon as it lands.")) + ); @@ -10764,7 +10952,9 @@ diff --git a/client.js b/client.js + const error = payload.error ?? null; + return (0, import_react16.createElement)( + "div", -+ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, ++ { className: "bt-del-body", "data-view": "board", "data-state": state }, ++ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), ++ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, + (0, import_react16.createElement)( "div", - { key: `pending-${id}`, className: "bt-activity" }, @@ -10787,7 +10977,8 @@ diff --git a/client.js b/client.js + const missing = payload.missing ?? []; + return (0, import_react16.createElement)( + "div", -+ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, ++ { className: "bt-del-body", "data-view": "board", "data-state": state }, ++ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), + message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : null, + state === "stale" && payload.error ? (0, import_react16.createElement)( + "p", @@ -10800,12 +10991,98 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), + // A workflow's own columns come first: a card that names a workflow is drawn there and not in + // the five columns below, which keep every card that names none (#1272 step 3). -+ entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) : null, -+ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : (0, import_react16.createElement)( ++ entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, ++ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( + "div", + { className: "bt-del-repos" }, -+ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) ++ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, filter })) + ) ++ ); ++} ++function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { ++ const [view, retry] = useDeliveryBoard(actions); ++ return (0, import_react16.createElement)(DeliveryBoard, { ++ view, ++ repo: view?.repo ?? null, ++ tasks, ++ roster, ++ dragging, ++ onMove, ++ onDrag, ++ onRetry: actions?.deliveryBoard ? retry : null, ++ onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null ++ }); ++} ++function DeliveryCard({ card, repo = null }) { ++ const model = githubCard(card, repo) ?? { source: "github", key: null, title: card?.title ?? "", badge: card?.badge ?? null }; ++ return (0, import_react16.createElement)(BoardCard, { ++ card: model, ++ skin: DELIVERY_SKIN, ++ attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0, "data-stage": card?.stage ?? void 0 }, ++ idText: `#${card?.number ?? "?"}`, ++ badge: badgeLabel(card?.badge) ++ }); ++} ++var DELIVERY_CSS = ` ++.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} ++.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} ++.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px} ++.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums} ++.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap} ++.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)} ++.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)} ++.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3);display:flex;flex-wrap:wrap;align-items:center;gap:6px} ++.bt-del-loading{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} ++.bt-del-hint{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-del-stale,.bt-del-partial{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-stale[role=status],.bt-del-partial[role=status]{padding:6px 8px;border-radius:10px;background:var(--bt-hover)} ++.bt-del-diag{display:inline-flex;position:relative} ++.bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} ++.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} ++.bt-del-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} ++.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} ++.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} ++.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} ++.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} ++.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} ++.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} ++.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} ++.bt-del-card-top{display:flex;align-items:center;gap:6px} ++.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} ++.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} ++.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} ++.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} ++.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} ++.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} ++.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} ++@media (max-width:800px){ ++ .bt-del-columns{overflow:auto} ++ .bt-del-column{min-width:220px} ++} ++@media (max-width:640px){ ++ .bt-del-columns{flex-direction:column} ++ .bt-del-column{min-width:0} ++} ++` + DELIVERY_CHART_CSS; ++ ++// src/client/kanban.js ++var import_react17 = require("react"); ++var POLL_MS4 = 15e3; ++function BoardIcon() { ++ return (0, import_react17.createElement)( ++ "svg", ++ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ); } -var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); @@ -10822,14 +11099,9 @@ diff --git a/client.js b/client.js - }; - visit(root); - return calls; -+function DeliveryView({ actions }) { -+ const [view, retry] = useDeliveryBoard(actions); -+ return (0, import_react16.createElement)(DeliveryBoard, { -+ view, -+ repo: view?.repo ?? null, -+ onRetry: actions?.deliveryBoard ? retry : null, -+ onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null -+ }); ++function shortId(id) { ++ const hex = String(id ?? "").replace(/^task-/, ""); ++ return `#${hex.slice(-6)}`; } -function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); @@ -10839,15 +11111,10 @@ diff --git a/client.js b/client.js - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); -+function DeliveryCard({ card, repo = null }) { -+ const model = githubCard(card, repo) ?? { source: "github", key: null, title: card?.title ?? "", badge: card?.badge ?? null }; -+ return (0, import_react16.createElement)(BoardCard, { -+ card: model, -+ skin: DELIVERY_SKIN, -+ attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0 }, -+ idText: `#${card?.number ?? "?"}`, -+ badge: badgeLabel(card?.badge) -+ }); ++var ARCHIVE_SHOWN = 20; ++function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { ++ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); ++ return [...tasks ?? [], ...newest.slice(0, limit)]; } -function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { - const tool = toolName(call2); @@ -10918,75 +11185,6 @@ diff --git a/client.js b/client.js - 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; -+var DELIVERY_CSS = ` -+.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} -+.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} -+.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px} -+.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums} -+.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap} -+.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)} -+.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)} -+.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3);display:flex;flex-wrap:wrap;align-items:center;gap:6px} -+.bt-del-loading{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -+.bt-del-hint{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-del-stale,.bt-del-partial{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-stale[role=status],.bt-del-partial[role=status]{padding:6px 8px;border-radius:10px;background:var(--bt-hover)} -+.bt-del-diag{display:inline-flex;position:relative} -+.bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} -+.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} -+.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} -+.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} -+.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} -+.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} -+.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} -+.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} -+.bt-del-card-top{display:flex;align-items:center;gap:6px} -+.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} -+.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -+.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} -+.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} -+.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} -+.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} -+.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} -+@media (max-width:800px){ -+ .bt-del-columns{overflow:auto} -+ .bt-del-column{min-width:220px} -+} -+@media (max-width:640px){ -+ .bt-del-columns{flex-direction:column} -+ .bt-del-column{min-width:0} -+} -+` + DELIVERY_CHART_CSS; -+ -+// src/client/kanban.js -+var import_react17 = require("react"); -+var POLL_MS4 = 15e3; -+function BoardIcon() { -+ return (0, import_react17.createElement)( -+ "svg", -+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) -+ ); -+} -+function shortId(id) { -+ const hex = String(id ?? "").replace(/^task-/, ""); -+ return `#${hex.slice(-6)}`; -+} -+var ARCHIVE_SHOWN = 20; -+function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { -+ const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); -+ return [...tasks ?? [], ...newest.slice(0, limit)]; -+} +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { @@ -11058,7 +11256,7 @@ diff --git a/client.js b/client.js + const bot = roster?.byId?.[task?.ownerBotId]; + return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); +} -+function priorityLabel(priority) { ++function priorityLabel2(priority) { + if (priority === "urgent") return t("Highest"); + if (priority === "high") return t("High"); + if (priority === "low") return t("Low"); @@ -11355,7 +11553,7 @@ diff --git a/client.js b/client.js + idText: shortId(task.id), + emptyTitle: t("Untitled task"), + topExtra: [ -+ (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), ++ (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel2(priority)), + (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + ], + note: task.note ?? "", @@ -11539,8 +11737,49 @@ diff --git a/client.js b/client.js + if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; + return ""; +} ++function UnifiedBoard({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { ++ const selectedBot = scope?.kind === "bot" ? scope.botId : ""; ++ const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; ++ const lanes = scope?.kind === "group"; ++ return (0, import_react17.createElement)( ++ "div", ++ { className: "bt-board-body" }, ++ (0, import_react17.createElement)( ++ "aside", ++ { className: "bt-board-rail", "aria-label": t("Task board") }, ++ (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), ++ rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { ++ const count = counts2?.[row.id] ?? { done: 0, total: 0 }; ++ return (0, import_react17.createElement)(RailButton, { ++ key: row.id, ++ row, ++ selected: row.id === selectedBot, ++ onSelect, ++ meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" ++ }); ++ }), ++ rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, ++ rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) ++ ), ++ (0, import_react17.createElement)( ++ "div", ++ { className: "bt-board-main" }, ++ scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( ++ "form", ++ { className: "bt-board-add", onSubmit: (event) => { ++ event.preventDefault(); ++ onCreate(draft); ++ } }, ++ (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), ++ (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) ++ ) : null, ++ error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, ++ lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, ++ (0, import_react17.createElement)(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }) ++ ) ++ ); ++} +function KanbanDialog({ roster, actions, leaving = false }) { -+ const [view, setView] = (0, import_react17.useState)("tasks"); + const rail = boardRail(roster); + const [scope, setScope] = (0, import_react17.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); + const [dragging, setDragging] = (0, import_react17.useState)(null); @@ -11590,18 +11829,12 @@ diff --git a/client.js b/client.js + (0, import_react17.createElement)( + "div", + { className: "bt-mem-head" }, -+ (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, view === "delivery" ? t("Delivery") : t("Tasks")), -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-board-views", role: "tablist", "aria-label": t("Board view") }, -+ (0, import_react17.createElement)("button", { type: "button", role: "tab", className: "bt-soft bt-soft-sm", "data-view": "tasks", "aria-selected": view === "tasks" ? "true" : "false", onClick: () => setView("tasks") }, t("Tasks")), -+ (0, import_react17.createElement)("button", { type: "button", role: "tab", className: "bt-soft bt-soft-sm", "data-view": "delivery", "aria-selected": view === "delivery" ? "true" : "false", onClick: () => setView("delivery") }, t("Delivery")) -+ ), -+ view === "tasks" ? (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name) : null, ++ (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, t("Board")), ++ (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name), + (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close tasks"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) ++ (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) + ), -+ view === "delivery" ? (0, import_react17.createElement)(DeliveryView, { actions }) : (0, import_react17.createElement)(BoardView, { ++ (0, import_react17.createElement)(UnifiedBoard, { + roster, + tasks: boardTasks, + rail, @@ -11610,6 +11843,7 @@ diff --git a/client.js b/client.js + dragging, + error, + draft, ++ actions, + onDraft: setDraft, + onSelect: setScope, + onMove: move, diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 1a8e49919..2b3a0004d 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -6,11 +6,15 @@ // `loading` counts the seconds, `failed` names the endpoint, the status and what to do about it, // `stale` keeps the old board and hangs the reason beside it, `partial` says which part was not // read. The poll is fast while a round is running (2 s) and slow once there is something to read. +// +// #1326: One board that shows both task cards and delivery cards together, columns from workflow +// definitions, filters for stages with column:false (review, done-today). import { createElement as h, useEffect, useState } from 'react' import { t } from './i18n.js' +import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN } from './board-card.js' +import { BoardCard, DELIVERY_SKIN, TASK_SKIN } from './board-card.js' const POLL_MS = 60_000 // While the host is still building there is nothing to see, so ask often; once the board is up, @@ -32,12 +36,12 @@ export function pollDelay(state, payload = null, now = Date.now()) { return POLL_MS } +// #1326: Columns from workflow definitions. The three visible stages of repo-dev. +// Stages with column:false (review, done-today) are filters, not columns. export const DELIVERY_COLUMNS = [ - { id: 'raw', label: 'Original issue' }, - { id: 'ready', label: 'Ready to start' }, - { id: 'working', label: 'In progress' }, - { id: 'review', label: 'In review' }, - { id: 'done-today', label: 'Done today' }, + { id: 'not-started', label: 'Not started' }, + { id: 'in-progress', label: 'In progress' }, + { id: 'done', label: 'Done' }, ] // The columns to draw for one payload, in one place (#1265 step 4). The snapshot names which @@ -68,6 +72,84 @@ const BADGES = { export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '') +// #1326: Task cards in workflow columns. Task status maps to column: open/claimed → not-started, +// working/blocked/result-ready → in-progress, accepted/cancelled/failed → done. +function taskColumnOf(task) { + const col = columnOf(task) + if (col === 'not-started') return 'not-started' + if (col === 'in-progress') return 'in-progress' + return 'done' +} + +// A task on the one board reads through the same card a delivery card does (#1265 step 1, #1326): +// one identity line, one title, and the Bot that has it — only the source differs. +const priorityLabel = (priority) => t(priority === 'urgent' ? 'Highest' : priority === 'high' ? 'High' : 'Low') +const taskShortId = (id) => `#${String(id ?? '').replace('task-', '').slice(-6)}` +function taskCardModel(task, roster) { + return { + source: 'task', + key: `task:${task?.id}`, + title: task?.title ?? '', + owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, + ownerKind: 'bot', + } +} + +// A column takes a card dropped on it, and the board's own `onMove` decides whether that move is +// allowed — the drop is a request, never a write from the client (#1326). +const dropProps = (column, { dragging, onMove, onDrag }) => ({ + onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, + onDrop: (event) => { + event.preventDefault() + const id = event.dataTransfer?.getData('text/plain') ?? '' + if (id) onMove?.(id, column) + onDrag?.(null) + }, +}) + +const taskCardNode = (task, { roster, onMove, onDrag }) => h(BoardCard, { + key: task.id, + card: taskCardModel(task, roster), + skin: TASK_SKIN, + attrs: { 'data-task': task.id }, + idText: taskShortId(task.id), + topExtra: task.priority && task.priority !== 'normal' + ? h('span', { className: 'bt-board-priority', 'data-priority': task.priority }, priorityLabel(task.priority)) + : null, + draggable: Boolean(onMove), + dragValue: task.id, + onDragStart: () => onDrag?.(task.id), + onDragEnd: () => onDrag?.(null), +}) + +// Simplified task column renderer for degraded states (no GitHub snapshot). +function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = { 'not-started': [], 'in-progress': [], 'done': [] } + for (const task of tasks ?? []) { + const col = taskColumnOf(task) + if (columns[col]) columns[col].push(task) + } + const columnTitles = { + 'not-started': t('Not started'), + 'in-progress': t('In progress'), + 'done': t('Done'), + } + return h('div', { className: 'bt-board-columns' }, + ['not-started', 'in-progress', 'done'].map(column => h('section', { + key: column, + className: 'bt-board-column', + 'data-column': column, + 'aria-label': columnTitles[column], + ...dropProps(column, { dragging, onMove, onDrag }), + }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, columnTitles[column]), + h('span', { className: 'bt-board-count' }, String(columns[column].length))), + columns[column].length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) + : columns[column].map(task => taskCardNode(task, { roster, onMove, onDrag }))))) +} + export function duration(ms) { if (!Number.isFinite(ms) || ms < 0) return '—' const hours = ms / 3_600_000 @@ -357,28 +439,75 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on onProbe ? h(Diagnose, { onProbe }) : null)) } -// One repository's columns (#1265 step 4). The card key carries the repository, so the same issue -// number in two repositories is two cards and neither is mistaken for the other. -function DeliveryColumns({ view, repo = null, placed = null }) { +// #1326: the stages a definition draws no column for — a review, today's finished cards — are +// filters, not lanes. A filter narrows which cards a column shows; it never reorders the columns, and +// one board keeps one set of them. +const BOARD_FILTERS = ['all', 'review', 'done-today'] +function BoardFilters({ filter, onFilter }) { + return h('div', { className: 'bt-del-filters', role: 'group', 'aria-label': t('Filter') }, + BOARD_FILTERS.map(id => h('button', { + key: id, + type: 'button', + className: 'bt-soft bt-soft-sm', + 'data-filter': id, + 'aria-pressed': filter === id ? 'true' : 'false', + onClick: () => onFilter(id), + }, id === 'all' ? t('All') : id === 'review' ? t('Review') : t('Done today')))) +} + +const stageOf = (card, column) => card?.stage ?? column +const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter + +// One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. +function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = view?.columns ?? {} // A card a workflow section already draws is not drawn again here (#1272 step 3): the section owns // it, and the five legacy columns keep every card that names no workflow. const drawn = placed === null || placed === undefined ? null : new Set(placed) - const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) + const deliveryCards = (id) => (columns[id] ?? []) + .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) + .filter((card) => matchesFilter(card, id, filter)) + + // #1326: Task cards map into the same columns. Map task column to delivery column. + const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } + // A task card names no review or done-today stage, so a stage filter narrows it away rather than + // moving it to a lane it never named (#1326). + for (const task of filter === 'all' ? tasks ?? [] : []) { + const col = taskColumnOf(task) + if (tasksByColumn[col]) tasksByColumn[col].push(task) + } + + const allCards = (id) => { + const delivery = deliveryCards(id) + const taskList = tasksByColumn[id] ?? [] + return [...taskList, ...delivery] + } + + // The two kinds share these columns, so which one a card is has to come from where it was read, + // never from guessing at its fields. + const taskIds = new Set((tasks ?? []).map(task => task?.id)) + const isTask = (item) => taskIds.has(item?.id) + return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, + stagesOf(view).map(column => h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, + ...dropProps(column.id, { dragging, onMove, onDrag }), + }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(cards(column.id).length))), - cards(column.id).length === 0 + h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), + allCards(column.id).length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : cards(column.id).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) + : allCards(column.id).map(item => + isTask(item) + ? taskCardNode(item, { roster, onMove, onDrag }) + : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, // the columns in the definition's own order, and every header naming the workflow it belongs to — so // two workflows never share a column and a reader can always tell whose column it is. -function DeliveryWorkflowSections({ sections, repo = null }) { +function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { const list = Array.isArray(sections) ? sections : [] if (list.length === 0) return null return h('div', { className: 'bt-del-workflows' }, list.map(section => h('section', { @@ -387,16 +516,19 @@ function DeliveryWorkflowSections({ sections, repo = null }) { h('h3', { className: 'bt-del-workflow-name' }, h('span', null, section.name), h('span', { className: 'bt-wf-path' }, `workflows/${section.id}.json`)), - h('div', { className: 'bt-del-columns' }, section.columns.map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, - }, - h('h4', { className: 'bt-board-column-head' }, - h('span', null, column.label), - h('span', { className: 'bt-del-workflow-tag' }, section.name), - h('span', { className: 'bt-board-count' }, String(column.cards.length))), - column.cards.length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : column.cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))))), + h('div', { className: 'bt-del-columns' }, section.columns.map(column => { + const cards = column.cards.filter(card => matchesFilter(card, column.id, filter)) + return h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, + }, + h('h4', { className: 'bt-board-column-head' }, + h('span', null, column.label), + h('span', { className: 'bt-del-workflow-tag' }, section.name), + h('span', { className: 'bt-board-count' }, String(cards.length))), + cards.length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))) + })), (section.unplaced ?? []).length > 0 ? h('p', { className: 'bt-del-hint' }, t('{count} card(s) name a node this workflow draws no column for.', { count: section.unplaced.length })) : null))) @@ -404,7 +536,7 @@ function DeliveryWorkflowSections({ sections, repo = null }) { // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry }) { +function DeliveryRepoBlock({ entry, filter = 'all' }) { const state = entry?.state ?? 'fresh' const source = repoSourceText(entry?.repoSource) return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, @@ -414,10 +546,10 @@ function DeliveryRepoBlock({ entry }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter })) } -export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { +export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' // The countdown promises a number that moves, so the screen redraws once a second while a retry @@ -433,17 +565,25 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet return () => clearInterval(timer) }, [ticking, retryAt]) const countdown = retryCountdown(payload.retryAt, now) + // The filter is view state: it narrows which cards the columns show and never reorders them (#1326). + const [filter, setFilter] = useState('all') // More than one repository is one block per repository; exactly one is today's single board. const entries = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. + // #1326: Task cards still render when delivery fails. + const hasTasks = Array.isArray(tasks) && tasks.length > 0 if (state === 'idle') { - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : h('p', { className: 'bt-board-empty' }, t('No snapshot yet.'))) } if (state === 'loading') { - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, h('p', { className: 'bt-del-loading', role: 'status' }, loadingText(payload.elapsedMs)), h('p', { className: 'bt-del-hint' }, t('The first round reads the repository once; the columns appear as soon as it lands.'))) } @@ -452,7 +592,9 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet const hasBoard = Boolean(payload.columns) || (entries !== null && entries.some(entry => entry.columns !== undefined)) if (state === 'failed' || !hasBoard) { const error = payload.error ?? null - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), + hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, h('div', { className: 'bt-board-error', role: 'alert' }, h('p', null, String(error?.message ?? message ?? t('The delivery board is not ready yet.'))), error ? h('p', { className: 'bt-del-hint' }, hintFor(error, { repo })) : null, @@ -463,7 +605,8 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null)) } const missing = payload.missing ?? [] - return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, + return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, + h(BoardFilters, { filter, onFilter: setFilter }), message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, state === 'stale' && payload.error ? h('p', { className: 'bt-del-stale', role: 'status' }, staleText(payload.asOf, payload.error), @@ -477,19 +620,24 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet // A workflow's own columns come first: a card that names a workflow is drawn there and not in // the five columns below, which keep every card that names none (#1272 step 3). entries === null - ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) + ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, filter })))) } -export function DeliveryView({ actions }) { +export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { const [view, retry] = useDeliveryBoard(actions) return h(DeliveryBoard, { view, repo: view?.repo ?? null, + tasks, + roster, + dragging, + onMove, + onDrag, onRetry: actions?.deliveryBoard ? retry : null, onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null, }) @@ -502,7 +650,7 @@ export function DeliveryCard({ card, repo = null }) { return h(BoardCard, { card: model, skin: DELIVERY_SKIN, - attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined }, + attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined, 'data-stage': card?.stage ?? undefined }, idText: `#${card?.number ?? '?'}`, badge: badgeLabel(card?.badge), }) @@ -525,6 +673,8 @@ export const DELIVERY_CSS = ` .bt-del-diag{display:inline-flex;position:relative} .bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} +.bt-del-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} +.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} .bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index aac525cfe..cee4c91db 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -358,10 +358,36 @@ function scopeName(roster, scope) { return '' } +// #1326: one board that shows both task cards and delivery cards together, no tab split. +// Columns come from workflow definitions; task cards map into the same columns as delivery cards. +function UnifiedBoard({ roster, tasks, rail, scope, counts, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { + const selectedBot = scope?.kind === 'bot' ? scope.botId : '' + const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' + const lanes = scope?.kind === 'group' + return h('div', { className: 'bt-board-body' }, + h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, + h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), + rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { + const count = counts?.[row.id] ?? { done: 0, total: 0 } + return h(RailButton, { + key: row.id, row, selected: row.id === selectedBot, onSelect, + meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', + }) + }), + rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, + rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), + h('div', { className: 'bt-board-main' }, + scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, + h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), + h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, + error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, + lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, + h(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }))) +} + export function KanbanDialog({ roster, actions, leaving = false }) { - // Two views on one surface: the per-Bot task board (#1058) and the repository's delivery - // board (#1173). Delivery is repo-wide, so its rail would be a lie — it is not rendered. - const [view, setView] = useState('tasks') + // One board (#1326): task cards and delivery cards together, no tab split. The rail is per-Bot + // for task scope; delivery is repo-wide and reads from actions.deliveryBoard. const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) @@ -405,19 +431,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('div', { className: 'bt-scrim', onClick: close }), h('div', { className: 'bt-mem bt-board', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'bt-board-heading' }, h('div', { className: 'bt-mem-head' }, - h('h2', { id: 'bt-board-heading' }, view === 'delivery' ? t('Delivery') : t('Tasks')), - h('div', { className: 'bt-board-views', role: 'tablist', 'aria-label': t('Board view') }, - h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'tasks', 'aria-selected': view === 'tasks' ? 'true' : 'false', onClick: () => setView('tasks') }, t('Tasks')), - h('button', { type: 'button', role: 'tab', className: 'bt-soft bt-soft-sm', 'data-view': 'delivery', 'aria-selected': view === 'delivery' ? 'true' : 'false', onClick: () => setView('delivery') }, t('Delivery'))), - view === 'tasks' ? h('span', { className: 'bt-mem-status' }, name) : null, + h('h2', { id: 'bt-board-heading' }, t('Board')), + h('span', { className: 'bt-mem-status' }, name), h('span', { className: 'bt-mem-head-gap' }), - h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close tasks'), autoFocus: true, onClick: close }, h(CloseIcon))), - view === 'delivery' - ? h(DeliveryView, { actions }) - : h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, - onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, - }))) + h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), + h(UnifiedBoard, { + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, actions, + onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, + }))) } export const KANBAN_CSS = ` diff --git a/patches/ds-bot/files/src/host/delivery-board.js b/patches/ds-bot/files/src/host/delivery-board.js index 5310e339d..817a572bb 100644 --- a/patches/ds-bot/files/src/host/delivery-board.js +++ b/patches/ds-bot/files/src/host/delivery-board.js @@ -14,7 +14,8 @@ import { repoSlug } from './update.js' import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' import { boardSections } from '../shared/workflow-board.js' -export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] +// #1326: Workflow-based columns. The three visible stages of repo-dev; review and done-today are filters. +export const DELIVERY_COLUMNS = ['not-started', 'in-progress', 'done'] // State labels first on a card, then the areas; the order is the order the board shows. const STATE_LABELS = ['needs-human', 'needs-info', 'agent-ready', 'agent-working', 'agent-pr', 'awaiting-approval', 'hold'] @@ -145,12 +146,14 @@ export function cardOf(row, { kind, linked = null, owner = null, badge = null, s } } +// #1326: Map delivery stage to workflow columns. PRs are in-progress (build/verify/gate); +// review is a filter (column:false). Issues map by labels. export function columnOfDelivery(row, { kind }) { const labels = labelNames(row) - if (kind === 'pr') return 'review' - if (labels.includes('agent-working')) return 'working' - if (labels.includes('agent-ready')) return 'ready' - return 'raw' + if (kind === 'pr') return 'in-progress' + if (labels.includes('agent-working') || labels.includes('agent-pr')) return 'in-progress' + if (labels.includes('agent-ready')) return 'not-started' + return 'not-started' } // Which part of the board a path belongs to. The view turns this into advice a person can act on @@ -543,7 +546,8 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = let mergedToday = [] let todayMetrics = null let weekMetrics = null - const columns = { raw: [], ready: [], working: [], review: [], 'done-today': [] } + // #1326: Three workflow columns; done-today cards go in 'done' with data-stage='done-today' for filtering. + const columns = { 'not-started': [], 'in-progress': [], 'done': [] } try { // The two open lists are the board itself: when they cannot be read there is no board to // show, so that failure is the round's, not a part's (#1211). @@ -620,7 +624,13 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = if (error?.budget) { note('budget', error); break } note(`comments:${pr.number}`, error) } - columns.review.push({ ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), badgeBasis: basis }) + // An open pull request is work in progress, and `review` is a stage a card carries: the + // definition draws no column for it (#1326), so the card is placed by `columnOfDelivery`. + columns[columnOfDelivery(pr, { kind: 'pr' })].push({ + ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), + badgeBasis: basis, + stage: 'review', + }) } const closedInDay = (row) => Date.parse(row.closed_at ?? '') >= today const doneIssues = closedToday.filter(row => !isPull(row) && closedInDay(row)) @@ -632,10 +642,12 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = // settle it even when that row carries no merge time. const mergedNumbers = new Set(donePulls.map(row => row.number)) const abandonedPulls = closedToday.filter(row => isPull(row) && !row.pull_request?.merged_at && !mergedNumbers.has(row.number) && closedInDay(row)) - for (const row of donePulls) columns['done-today'].push(cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null })) - for (const row of abandonedPulls) columns['done-today'].push(cardOf(row, { kind: 'pr' })) - for (const row of doneIssues) columns['done-today'].push(cardOf(row, { kind: 'issue' })) - columns['done-today'] = columns['done-today'].slice(0, MAX_DONE) + // #1326: done-today cards go in 'done' column with stage='done-today' for filtering. + for (const row of donePulls) columns.done.push({ ...cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }), stage: 'done-today' }) + for (const row of abandonedPulls) columns.done.push({ ...cardOf(row, { kind: 'pr' }), stage: 'done-today' }) + for (const row of doneIssues) columns.done.push({ ...cardOf(row, { kind: 'issue' }), stage: 'done-today' }) + // done column holds all done cards; done-today is a filter, not a separate column. + columns.done = columns.done.slice(0, MAX_DONE) const needsHuman = issues.filter(row => labelNames(row).includes('needs-human') || labelNames(row).includes('needs-info')).length const openPrs = pullRows.length return { diff --git a/patches/ds-bot/files/test/delivery-board.test.js b/patches/ds-bot/files/test/delivery-board.test.js index f4c97c9f2..929c4fa2a 100644 --- a/patches/ds-bot/files/test/delivery-board.test.js +++ b/patches/ds-bot/files/test/delivery-board.test.js @@ -223,8 +223,9 @@ test('an unreadable /actions/runs leaves the board, and the CI number unknown no assert.equal(result.partial, true) assert.equal(result.ok, true) // The board is still there: one failed request empties nothing. - assert.deepEqual(result.columns.working.map(card => card.number), [12]) - assert.deepEqual(result.counts.review, 2) + // The column keeps its cards and the two open pull requests; only CI is missing. + assert.deepEqual(result.columns['in-progress'].map(card => card.number), [12, 40, 41]) + assert.deepEqual(result.counts['in-progress'], 3) // Unknown is null, never 0 — and the reason is named. assert.equal(result.metrics.today.ci, null) assert.equal(result.metrics.week.ci, null) @@ -236,7 +237,7 @@ test('an unreadable /actions/runs leaves the board, and the CI number unknown no test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async () => { const { board } = build({ failures: { '/commits/bbbbbbb2/check-runs': { status: 404 } } }) const result = await settle(board) - const card = result.columns.review.find(item => item.number === 41) + const card = result.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'checks-unknown') assert.equal(result.state, 'partial') const entry = result.missing.find(item => item.part === 'checks:41') @@ -246,7 +247,7 @@ test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async test('unreadable comments cost that card its owner, not the board (#1211)', async () => { const { board } = build({ failures: { '/issues/12/comments': { status: 404 } } }) const result = await settle(board) - const card = result.columns.working.find(item => item.number === 12) + const card = result.columns['in-progress'].find(item => item.number === 12) assert.equal(card.owner, null) assert.equal(result.state, 'partial') assert.ok(result.missing.some(item => item.part === 'comments:12')) @@ -267,7 +268,7 @@ test('a failed refresh keeps the old snapshot and its reason (#1211)', async () assert.equal(stale.state, 'stale') assert.equal(stale.error.status, 404) assert.equal(stale.asOf, first.asOf, 'the old snapshot is still served') - assert.equal(stale.columns.working.length, 1) + assert.equal(stale.columns['in-progress'].length, 3) }) test('after a failure the next round waits 10 s, then 30 s, then two minutes (#1211)', async () => { @@ -307,7 +308,7 @@ test('a round past its budget lands as partial, not as a failure (#1211)', async assert.equal(result.state, 'partial') assert.equal(result.ok, true) assert.equal(result.partial, true) - assert.ok(Array.isArray(result.columns.raw)) + assert.ok(Array.isArray(result.columns['not-started'])) assert.equal(BUILD_BUDGET_MS, 90_000) }) @@ -347,21 +348,20 @@ test('the --probe CLI is a shell over probeGitHub, not a second implementation ( assert.doesNotMatch(source, /api\.github\.com/, 'the CLI makes no request of its own') }) -test('five columns come from one snapshot, an issue and its PR linked (#1173)', async () => { +test('three workflow columns from one snapshot, an issue and its PR linked (#1173, #1326)', async () => { const { board } = build() const snapshot = await settle(board) assert.deepEqual(Object.keys(snapshot.columns), DELIVERY_COLUMNS) - assert.deepEqual(snapshot.columns.raw.map(card => card.number), [10, 13]) - assert.deepEqual(snapshot.columns.ready.map(card => card.number), [11]) - assert.deepEqual(snapshot.columns.working.map(card => card.number), [12]) - assert.deepEqual(snapshot.columns.review.map(card => card.number), [40, 41]) - assert.deepEqual(snapshot.columns['done-today'].map(card => card.number), [41, 14]) - const working = snapshot.columns.working[0] - assert.equal(working.linked, 41) - assert.equal(working.owner, null) - const ready = snapshot.columns.ready[0] - assert.deepEqual(ready.labels, ['agent-ready', 'enhancement']) - assert.deepEqual(snapshot.counts, { raw: 2, ready: 1, working: 1, review: 2, 'done-today': 2 }) + // #1326: Three workflow columns. Issues map to not-started, PRs to in-progress, done-today in done. + assert.deepEqual(snapshot.columns['not-started'].map(card => card.number).sort((a, b) => a - b), [10, 11, 13]) + assert.deepEqual(snapshot.columns['in-progress'].map(card => card.number).sort((a, b) => a - b), [12, 40, 41]) + assert.deepEqual(snapshot.columns.done.filter(c => c.stage === 'done-today').map(card => card.number).sort((a, b) => a - b), [14, 41]) + const progress = snapshot.columns['in-progress'].find(c => c.number === 12) + assert.equal(progress.linked, 41) + assert.equal(progress.owner, null) + const notStarted = snapshot.columns['not-started'].find(c => c.number === 11) + assert.deepEqual(notStarted.labels, ['agent-ready', 'enhancement']) + assert.deepEqual(snapshot.counts, { 'not-started': 3, 'in-progress': 3, done: 2 }) }) test('only a closing keyword links a pull request to an issue (#1189)', async () => { @@ -375,10 +375,10 @@ test('only a closing keyword links a pull request to an issue (#1189)', async () const card = (column, number) => snapshot.columns[column].find(item => item.number === number) // "also see #10, same as #12" is prose: #10 links to nothing, and #12 keeps the pull request // that closes it (41), not the one that only mentions it. - assert.equal(card('raw', 10).linked, null) - assert.equal(card('working', 12).linked, 41) + assert.equal(card('not-started', 10).linked, null) + assert.equal(card('in-progress', 12).linked, 41) // A closing keyword with a colon counts; nothing else does. - assert.equal(card('raw', 13).linked, 44) + assert.equal(card('not-started', 13).linked, 44) }) test('a pull request closed today without a merge is done today, not nowhere (#1173)', async () => { @@ -390,7 +390,7 @@ test('a pull request closed today without a merge is done today, not nowhere (#1 ], }) const snapshot = await settle(board) - const done = snapshot.columns['done-today'] + const done = snapshot.columns.done // Acceptance 2.5: today's merges plus today's closures, and a closed pull request counts even // though it never merged. Yesterday's closure stays off the board. assert.equal(done.filter(card => card.number === 41).length, 1) @@ -401,7 +401,7 @@ test('a pull request closed today without a merge is done today, not nowhere (#1 // /issues?state=closed returns no head for a pull, so this card carries no merge commit. assert.equal(closed.sha, null) assert.equal(done.some(card => card.number === 53), false) - assert.equal(snapshot.counts['done-today'], done.length) + assert.equal(snapshot.counts.done, done.length) }) test('a pull merged today is done once although the closed page lists it too (#1173)', async () => { @@ -418,10 +418,10 @@ test('a pull merged today is done once although the closed page lists it too (#1 ], }) const snapshot = await settle(board) - const done = snapshot.columns['done-today'] + const done = snapshot.columns.done for (const number of [41, 44, 14, 52]) assert.equal(done.filter(card => card.number === number).length, 1, `#${number} is done today once`) assert.equal(done.some(card => card.number === 15), false) - assert.equal(snapshot.counts['done-today'], done.length) + assert.equal(snapshot.counts.done, done.length) }) test('a verdict with no Attestation still counts at the sha it names (#1173)', async () => { @@ -430,7 +430,7 @@ test('a verdict with no Attestation still counts at the sha it names (#1173)', a checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'pass-enqueue') }) @@ -442,7 +442,7 @@ test('a verdict line wins over an Attestation that names an older sha (#1173)', checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'pass-enqueue') }) @@ -460,7 +460,7 @@ test('a verdict on a later page of comments still decides the badge (#1189)', as checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const card = snapshot.columns.review.find(item => item.number === 41) + const card = snapshot.columns['in-progress'].find(item => item.number === 41) assert.equal(card.badge, 'inc') assert.ok(github.calls.some(call => call.path === '/issues/41/comments' && /[?&]page=2(&|$)/.test(call.query))) }) @@ -519,7 +519,7 @@ test('a PR badge reads the verdict at its own tip, and CI when there is none (#1 checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, }) const snapshot = await settle(board) - const badges = Object.fromEntries(snapshot.columns.review.map(card => [card.number, card.badge])) + const badges = Object.fromEntries(snapshot.columns['in-progress'].map(card => [card.number, card.badge])) // Verdict names an older sha: it does not count at this tip, so CI speaks. assert.equal(badges[40], 'ci-running') assert.equal(badges[41], 'pass-enqueue') @@ -983,9 +983,9 @@ test('two repositories are two blocks of one board, each with its own source (#1 assert.ok(two.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/two'))) // The union carries both repositories' cards: the same issue number in two repositories is two // cards, and the second one does not disappear behind the first. - assert.deepEqual(settled.repos[0].columns.raw.map(card => card.number).sort(), [10, 13]) - assert.deepEqual(settled.repos[1].columns.raw.map(card => card.number).sort(), [10, 13]) - assert.equal(settled.columns.raw.length, 4, 'the same number from each repository is its own card') + assert.deepEqual(settled.repos[0].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) + assert.deepEqual(settled.repos[1].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) + assert.equal(settled.columns['not-started'].length, 6, 'the same number from each repository is its own card') // The header's numbers are the repositories' sums; a median across repositories is not a median, // so it is unknown instead of invented. assert.equal(settled.metrics.today.newIssues, @@ -1039,8 +1039,8 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi const stamp = '2026-10-25T04:00:00Z' const view = (repo, extra = {}) => ({ ok: true, state: 'fresh', repo, repoSource: 'config', asOf: stamp, since: stamp, attempt: 1, elapsedMs: 10, - computedMs: 10, columns: { raw: [], ready: [], working: [], review: [], 'done-today': [] }, - counts: { raw: 0, ready: 0, working: 0, review: 0, 'done-today': 0 }, + computedMs: 10, columns: { 'not-started': [], 'in-progress': [], done: [] }, + counts: { 'not-started': 0, 'in-progress': 0, done: 0 }, metrics: { dayStart: stamp, timeZone: 'Asia/Shanghai', today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: 1, medianPullMergeMs: 2, ci: { failed: 0, total: 1, counted: 1, superseded: 0 } }, diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index c7d976392..544d4faa8 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -143,8 +143,25 @@ const snapshot = { }, } -test('five delivery columns, localized labels, badge wording (#1173)', () => { - assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['raw', 'ready', 'working', 'review', 'done-today']) +// #1326: the host draws the definition's three lanes; `review` and `done-today` are stages a card +// carries, not lanes of their own. +const threeLane = { + ...snapshot, + columns: { + 'not-started': [ + { kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }, + { kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }, + ], + 'in-progress': [ + { kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }, + { kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1', stage: 'review' }, + ], + done: [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40', stage: 'done-today' }], + }, +} + +test('three delivery columns, localized labels, badge wording (#1173, #1326)', () => { + assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['not-started', 'in-progress', 'done']) assert.equal(badgeLabel('inc'), 'INC · rework') assert.equal(badgeLabel('pass-enqueue'), 'PASS · waiting for enqueue') assert.equal(badgeLabel('pass-merge'), 'PASS · waiting for merge') @@ -645,15 +662,62 @@ const repoSample = (repo, extra = {}) => ({ ...extra, }) -test('the visible columns come from the payload through one entry point (#1265)', () => { - assert.equal(stagesOf({ columns: snapshot.columns }).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) - // The snapshot says which columns exist; a definition that ships three draws three, not five. - assert.equal(stagesOf({ columns: { raw: [], working: [], 'done-today': [] } }).map(column => column.id).join('|'), 'raw|working|done-today') +test('the visible columns come from the payload through one entry point (#1265, #1326)', () => { + // The payload names the columns it has; the default is the definition's three lanes. + assert.equal(stagesOf({ columns: threeLane.columns }).map(column => column.id).join('|'), 'not-started|in-progress|done') // An id this build has no label for still renders under its own id rather than disappearing. assert.equal(stagesOf({ columns: { review: [], triage: [] } }).map(column => column.id).join('|'), 'review|triage') assert.equal(stagesOf({}).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) }) +test('#1326: the stages that draw no lane are filters, and a filter narrows without reordering', () => { + const board = mount(DeliveryBoard, { snapshot: threeLane, message: '' }) + // The definition ships three lanes; review and done-today are stages, so no fourth or fifth lane. + const lanes = (tree) => walk(tree).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => node.props['data-column']) + assert.deepEqual(lanes(board.draw()), ['not-started', 'in-progress', 'done']) + const bar = walk(board.draw()).find(node => node.props?.className === 'bt-del-filters') + assert.ok(bar, 'the board draws one filter bar') + const filterButtons = walk(bar).filter(node => node.type === 'button') + assert.deepEqual(filterButtons.map(node => node.props['data-filter']), ['all', 'review', 'done-today']) + assert.deepEqual(filterButtons.map(node => node.props['aria-pressed']), ['true', 'false', 'false']) + // A finished card keeps the stage it came with, so a stage assertion needs no fifth lane. + const done = walk(board.draw()).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') + assert.equal(walk(done).some(node => node.props?.['data-stage'] === 'done-today'), true) + // Pressing a filter narrows the cards and leaves the columns in their order. + filterButtons[1].props.onClick() + const reviewOnly = board.draw() + assert.deepEqual(lanes(reviewOnly), ['not-started', 'in-progress', 'done']) + const pressed = walk(reviewOnly).find(node => node.props?.className === 'bt-del-filters') + assert.deepEqual(walk(pressed).filter(node => node.type === 'button').map(node => node.props['aria-pressed']), ['false', 'true', 'false']) + const shown = walk(reviewOnly).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => walk(node).filter(item => item.props?.['data-number']).map(item => item.props['data-number'])) + assert.deepEqual(shown, [[], [41], []]) +}) + +test('#1326: a task card is the shared card, and a drop only asks the board to move it', () => { + const moved = [] + const board = mount(DeliveryBoard, { + view: { ...threeLane, state: 'fresh' }, + tasks: [{ id: 'task-abc123456', title: 'Draft the note', status: 'working', ownerBotId: 's', column: 'in-progress', priority: 'high' }], + roster: { byId: { s: { name: 'Writer' } } }, + dragging: 'task-abc123456', + onMove: (id, column) => moved.push([id, column]), + onDrag: (id) => moved.push(['drag', id]), + }) + const tree = board.draw() + const card = walk(tree).find(node => node.props?.['data-task'] === 'task-abc123456') + assert.equal(card.props['data-source'], 'task') + assert.equal(card.props['data-key'], 'task:task-abc123456') + assert.match(text(card), /Draft the note/) + assert.match(text(card), /Writer/, 'the card names the Bot that has the task') + assert.match(text(card), /High/) + // Dragging reports the card, and the drop reports the column it landed on: the host decides, the + // view never writes (#1326). + card.props.onDragStart({ dataTransfer: { setData: () => {} } }) + const done = walk(tree).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') + done.props.onDrop({ preventDefault: () => {}, dataTransfer: { getData: () => 'task-abc123456' } }) + assert.deepEqual(moved, [['drag', 'task-abc123456'], ['task-abc123456', 'done'], ['drag', null]]) +}) + test('more than one repository draws one block each, one repository draws none (#1265)', () => { // One repository keeps today's shape: the header names it and no block wrapper is rendered. const single = DeliveryBoard({ snapshot: { ...snapshot, repo: 'acme/one' }, repo: 'acme/one', message: '' }) diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index a64ff0612..6900c62e3 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -175,31 +175,30 @@ test('a group column nests subtasks under the overall task', () => { assert.match(textOf(notStarted), /Theirs/) }) -test('the Delivery view drops the Bot rail, while Tasks keeps it (#1173)', () => { +test('one board: no tablist, heading says Board, shows both task and delivery cards (#1326)', () => { const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => ({ ok: false, reason: 'no-github' }) } states.length = 0 - const tasksView = expand(KanbanDialog({ roster, actions })) - const tabs = all(tasksView).filter(node => node.props?.role === 'tab') - assert.equal(tabs.map(node => node.props['data-view']).join('|'), 'tasks|delivery') - assert.equal(tabs.find(node => node.props['data-view'] === 'tasks').props['aria-selected'], 'true') - assert.equal(byClass(tasksView, 'bt-board-rail').length, 1) - assert.equal(all(tasksView).some(node => node.props?.['data-view'] === 'delivery' && node.props?.className === 'bt-del-body'), false) - - states.push('delivery') - const deliveryView = expand(KanbanDialog({ roster, actions })) - // The rail is per-Bot; the delivery board is one per repository, so it must not carry one. - assert.equal(byClass(deliveryView, 'bt-board-rail').length, 0) - assert.equal(byClass(deliveryView, 'bt-board-body').length, 0) - assert.equal(all(deliveryView).some(node => node.props?.['data-view'] === 'delivery'), true) - assert.equal(all(deliveryView).find(node => node.props?.role === 'tab' && node.props['data-view'] === 'delivery').props['aria-selected'], 'true') - // No snapshot yet is the honest one-line empty state until the first read lands. - assert.match(textOf(deliveryView), /No snapshot yet/) + const boardView = expand(KanbanDialog({ roster, actions })) + // No tablist — the Tasks/Delivery split is gone (#1326). + const tabs = all(boardView).filter(node => node.props?.role === 'tab') + assert.equal(tabs.length, 0, 'no tabs — one board shows both task and delivery cards') + const tablist = all(boardView).find(node => node.props?.role === 'tablist') + assert.equal(tablist, undefined, 'no tablist element') + // Heading says "Board", not "Tasks" or "Delivery". + const heading = all(boardView).find(node => node.props?.id === 'bt-board-heading') + assert.match(textOf(heading), /Board/) + assert.doesNotMatch(textOf(heading), /Tasks/) + assert.doesNotMatch(textOf(heading), /Delivery/) + // The rail is per-Bot; task cards need it, delivery cards do not. + assert.equal(byClass(boardView, 'bt-board-rail').length, 1) + // With no GitHub token, task cards still render — the board is never empty (#1211, #1265). + assert.equal(all(boardView).some(node => node.props?.className === 'bt-del-body'), true) }) -test('a delivery read with no token leaves the task cards and their tab in place (#1211, #1265)', () => { +test('a delivery read with no token still shows task cards on the one board (#1211, #1265, #1326)', () => { // The two halves meet in one dialog: the delivery board can only say "the GitHub side is not // available" (core/test/delivery-view.test.js holds that sentence), and it must not take the task - // board with it — the cards are the other view, and its tab still opens them. + // board with it — the cards are still rendered on the same board. const failed = { ok: false, state: 'failed', repo: null, repos: [], error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null }, @@ -207,13 +206,14 @@ test('a delivery read with no token leaves the task cards and their tab in place } const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => failed } const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) - // view, scope, dragging, error, draft, then the list the board would have read from the host. + // scope, dragging, error, draft, then the list the board would have read from the host. states.length = 0 - states.push('tasks', { kind: 'bot', botId: 's' }, null, '', '', [mine]) - const tasksView = expand(KanbanDialog({ roster, actions })) - assert.match(textOf(tasksView), /Draft the note/) - assert.equal(byClass(tasksView, 'bt-board-card').length, 1) - assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) + states.push({ kind: 'bot', botId: 's' }, null, '', '', [mine]) + const boardView = expand(KanbanDialog({ roster, actions })) + assert.match(textOf(boardView), /Draft the note/) + assert.equal(byClass(boardView, 'bt-board-card').length, 1) + // No tabs — one board shows both (#1326). + assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) // #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished From c6e72ec8ffe43b0a6de5e853b61bd6bb7ddb9d20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:20:43 +0000 Subject: [PATCH 3/5] Fix task card interaction and multi-repo task display (#1326) Finding 1 - Start/Complete missing: Replace taskCardNode (board-card.js BoardCard) with TaskBoardCard (kanban.js BoardCard) which has interactive Start/Complete buttons. Task cards now wire actions.taskUpdate through the onMove callback chain, enabling column moves from both buttons and drag-drop. Finding 2 - multi-repo drops tasks: Update DeliveryRepoBlock to accept and pass tasks, roster, onMove, onDrag to DeliveryColumns. Task cards now appear in multi-repo views alongside GitHub cards in shared columns. Both TaskColumns (degraded/no-token path) and DeliveryColumns (happy path with GitHub data) now render interactive task cards with full Start/Complete functionality. All tests green (1877 pass, 0 fail). Co-authored-by: Danyang Chen --- core/client.js | 44 +++++--------- core/src/client/delivery-view.js | 49 +++++---------- patches/ds-bot/0008-client-bundle.patch | 59 +++++++------------ .../ds-bot/files/src/client/delivery-view.js | 49 +++++---------- 4 files changed, 64 insertions(+), 137 deletions(-) diff --git a/core/client.js b/core/client.js index 93db6b363..b551132d0 100644 --- a/core/client.js +++ b/core/client.js @@ -8487,17 +8487,6 @@ function taskColumnOf(task) { if (col === "in-progress") return "in-progress"; return "done"; } -var priorityLabel = (priority) => t(priority === "urgent" ? "Highest" : priority === "high" ? "High" : "Low"); -var taskShortId = (id) => `#${String(id ?? "").replace("task-", "").slice(-6)}`; -function taskCardModel(task, roster) { - return { - source: "task", - key: `task:${task?.id}`, - title: task?.title ?? "", - owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, - ownerKind: "bot" - }; -} var dropProps = (column, { dragging, onMove, onDrag }) => ({ onDragOver: (event) => { if (!dragging) return; @@ -8510,18 +8499,6 @@ var dropProps = (column, { dragging, onMove, onDrag }) => ({ onDrag?.(null); } }); -var taskCardNode = (task, { roster, onMove, onDrag }) => (0, import_react16.createElement)(BoardCard, { - key: task.id, - card: taskCardModel(task, roster), - skin: TASK_SKIN, - attrs: { "data-task": task.id }, - idText: taskShortId(task.id), - topExtra: task.priority && task.priority !== "normal" ? (0, import_react16.createElement)("span", { className: "bt-board-priority", "data-priority": task.priority }, priorityLabel(task.priority)) : null, - draggable: Boolean(onMove), - dragValue: task.id, - onDragStart: () => onDrag?.(task.id), - onDragEnd: () => onDrag?.(null) -}); function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = { "not-started": [], "in-progress": [], "done": [] }; for (const task of tasks ?? []) { @@ -8551,7 +8528,14 @@ function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onD (0, import_react16.createElement)("span", null, columnTitles[column]), (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) ), - columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => taskCardNode(task, { roster, onMove, onDrag })) + columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => (0, import_react16.createElement)(BoardCard2, { + key: task.id, + task, + roster, + movable: canMoveTask(roster, task), + onMove, + onDrag + })) )) ); } @@ -8883,7 +8867,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter (0, import_react16.createElement)("span", null, t(column.label)), (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) ), - allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? taskCardNode(item, { roster, onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) + allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) )) ); } @@ -8926,7 +8910,7 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null ))); } -function DeliveryRepoBlock({ entry, filter = "all" }) { +function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { const state = entry?.state ?? "fresh"; const source = repoSourceText(entry?.repoSource); return (0, import_react16.createElement)( @@ -8943,7 +8927,7 @@ function DeliveryRepoBlock({ entry, filter = "all" }) { { className: "bt-del-block-state", role: "status" }, state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" - ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter }) + ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) ); } function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { @@ -9024,7 +9008,7 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( "div", { className: "bt-del-repos" }, - entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, filter })) + entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, tasks, roster, dragging, onMove, onDrag, filter })) ) ); } @@ -9191,7 +9175,7 @@ function canMoveTask(roster, task) { const bot = roster?.byId?.[task?.ownerBotId]; return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); } -function priorityLabel2(priority) { +function priorityLabel(priority) { if (priority === "urgent") return t("Highest"); if (priority === "high") return t("High"); if (priority === "low") return t("Low"); @@ -9319,7 +9303,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) idText: shortId(task.id), emptyTitle: t("Untitled task"), topExtra: [ - (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel2(priority)), + (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) ], note: task.note ?? "", diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 2b3a0004d..d25ecadc5 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -14,7 +14,8 @@ import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN, TASK_SKIN } from './board-card.js' +import { BoardCard, DELIVERY_SKIN } from './board-card.js' +import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' const POLL_MS = 60_000 // While the host is still building there is nothing to see, so ask often; once the board is up, @@ -81,20 +82,6 @@ function taskColumnOf(task) { return 'done' } -// A task on the one board reads through the same card a delivery card does (#1265 step 1, #1326): -// one identity line, one title, and the Bot that has it — only the source differs. -const priorityLabel = (priority) => t(priority === 'urgent' ? 'Highest' : priority === 'high' ? 'High' : 'Low') -const taskShortId = (id) => `#${String(id ?? '').replace('task-', '').slice(-6)}` -function taskCardModel(task, roster) { - return { - source: 'task', - key: `task:${task?.id}`, - title: task?.title ?? '', - owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, - ownerKind: 'bot', - } -} - // A column takes a card dropped on it, and the board's own `onMove` decides whether that move is // allowed — the drop is a request, never a write from the client (#1326). const dropProps = (column, { dragging, onMove, onDrag }) => ({ @@ -107,21 +94,6 @@ const dropProps = (column, { dragging, onMove, onDrag }) => ({ }, }) -const taskCardNode = (task, { roster, onMove, onDrag }) => h(BoardCard, { - key: task.id, - card: taskCardModel(task, roster), - skin: TASK_SKIN, - attrs: { 'data-task': task.id }, - idText: taskShortId(task.id), - topExtra: task.priority && task.priority !== 'normal' - ? h('span', { className: 'bt-board-priority', 'data-priority': task.priority }, priorityLabel(task.priority)) - : null, - draggable: Boolean(onMove), - dragValue: task.id, - onDragStart: () => onDrag?.(task.id), - onDragEnd: () => onDrag?.(null), -}) - // Simplified task column renderer for degraded states (no GitHub snapshot). function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = { 'not-started': [], 'in-progress': [], 'done': [] } @@ -147,7 +119,14 @@ function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onD h('span', { className: 'bt-board-count' }, String(columns[column].length))), columns[column].length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) - : columns[column].map(task => taskCardNode(task, { roster, onMove, onDrag }))))) + : columns[column].map(task => h(TaskBoardCard, { + key: task.id, + task, + roster, + movable: canMoveTask(roster, task), + onMove, + onDrag + }))))) } export function duration(ms) { @@ -500,7 +479,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) : allCards(column.id).map(item => isTask(item) - ? taskCardNode(item, { roster, onMove, onDrag }) + ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) } @@ -536,7 +515,7 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry, filter = 'all' }) { +function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { const state = entry?.state ?? 'fresh' const source = repoSourceText(entry?.repoSource) return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, @@ -546,7 +525,7 @@ function DeliveryRepoBlock({ entry, filter = 'all' }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag })) } export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { @@ -625,7 +604,7 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks entries === null ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, filter })))) + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, tasks, roster, dragging, onMove, onDrag, filter })))) } export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index bb1d237dd..2b6e8d126 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1866,7 +1866,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,7173 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,7157 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -10084,23 +10084,7 @@ diff --git a/client.js b/client.js - busy.current = false; - if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); - }); -+var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; -+function taskColumnOf(task) { -+ const col = columnOf(task); -+ if (col === "not-started") return "not-started"; -+ if (col === "in-progress") return "in-progress"; -+ return "done"; -+} -+var priorityLabel = (priority) => t(priority === "urgent" ? "Highest" : priority === "high" ? "High" : "Low"); -+var taskShortId = (id) => `#${String(id ?? "").replace("task-", "").slice(-6)}`; -+function taskCardModel(task, roster) { -+ return { -+ source: "task", -+ key: `task:${task?.id}`, -+ title: task?.title ?? "", -+ owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, -+ ownerKind: "bot" - }; +- }; - (0, import_react6.useEffect)(() => { - alive.current = true; - load(void 0); @@ -10128,6 +10112,12 @@ diff --git a/client.js b/client.js - 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); ++var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; ++function taskColumnOf(task) { ++ const col = columnOf(task); ++ if (col === "not-started") return "not-started"; ++ if (col === "in-progress") return "in-progress"; ++ return "done"; +} +var dropProps = (column, { dragging, onMove, onDrag }) => ({ + onDragOver: (event) => { @@ -10142,18 +10132,6 @@ diff --git a/client.js b/client.js } - return (0, import_react6.createElement)( +}); -+var taskCardNode = (task, { roster, onMove, onDrag }) => (0, import_react16.createElement)(BoardCard, { -+ key: task.id, -+ card: taskCardModel(task, roster), -+ skin: TASK_SKIN, -+ attrs: { "data-task": task.id }, -+ idText: taskShortId(task.id), -+ topExtra: task.priority && task.priority !== "normal" ? (0, import_react16.createElement)("span", { className: "bt-board-priority", "data-priority": task.priority }, priorityLabel(task.priority)) : null, -+ draggable: Boolean(onMove), -+ dragValue: task.id, -+ onDragStart: () => onDrag?.(task.id), -+ onDragEnd: () => onDrag?.(null) -+}); +function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = { "not-started": [], "in-progress": [], "done": [] }; + for (const task of tasks ?? []) { @@ -10218,7 +10196,14 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("span", null, columnTitles[column]), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) + ), -+ columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => taskCardNode(task, { roster, onMove, onDrag })) ++ columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => (0, import_react16.createElement)(BoardCard2, { ++ key: task.id, ++ task, ++ roster, ++ movable: canMoveTask(roster, task), ++ onMove, ++ onDrag ++ })) + )) ); } @@ -10748,7 +10733,7 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("span", null, t(column.label)), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) + ), -+ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? taskCardNode(item, { roster, onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) ++ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) + )) + ); } @@ -10801,7 +10786,7 @@ diff --git a/client.js b/client.js - actions.noteLive(sessionId, id, JSON.parse(key)); - return () => actions.noteLive(sessionId, id, null); - }, [sessionId, id, key]); -+function DeliveryRepoBlock({ entry, filter = "all" }) { ++function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { + const state = entry?.state ?? "fresh"; + const source = repoSourceText(entry?.repoSource); + return (0, import_react16.createElement)( @@ -10818,7 +10803,7 @@ diff --git a/client.js b/client.js + { className: "bt-del-block-state", role: "status" }, + state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), + entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" -+ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter }) ++ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) + ); } -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { @@ -10995,7 +10980,7 @@ diff --git a/client.js b/client.js + entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( + "div", + { className: "bt-del-repos" }, -+ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, filter })) ++ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, tasks, roster, dragging, onMove, onDrag, filter })) + ) + ); +} @@ -11256,7 +11241,7 @@ diff --git a/client.js b/client.js + const bot = roster?.byId?.[task?.ownerBotId]; + return Boolean(bot && !bot.remote && (!bot.ownerId || bot.ownerId === me)); +} -+function priorityLabel2(priority) { ++function priorityLabel(priority) { + if (priority === "urgent") return t("Highest"); + if (priority === "high") return t("High"); + if (priority === "low") return t("Low"); @@ -11553,7 +11538,7 @@ diff --git a/client.js b/client.js + idText: shortId(task.id), + emptyTitle: t("Untitled task"), + topExtra: [ -+ (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel2(priority)), ++ (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), + (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + ], + note: task.note ?? "", diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 2b3a0004d..d25ecadc5 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -14,7 +14,8 @@ import { t } from './i18n.js' import { columnOf } from '../shared/task-board.js' import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN, TASK_SKIN } from './board-card.js' +import { BoardCard, DELIVERY_SKIN } from './board-card.js' +import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' const POLL_MS = 60_000 // While the host is still building there is nothing to see, so ask often; once the board is up, @@ -81,20 +82,6 @@ function taskColumnOf(task) { return 'done' } -// A task on the one board reads through the same card a delivery card does (#1265 step 1, #1326): -// one identity line, one title, and the Bot that has it — only the source differs. -const priorityLabel = (priority) => t(priority === 'urgent' ? 'Highest' : priority === 'high' ? 'High' : 'Low') -const taskShortId = (id) => `#${String(id ?? '').replace('task-', '').slice(-6)}` -function taskCardModel(task, roster) { - return { - source: 'task', - key: `task:${task?.id}`, - title: task?.title ?? '', - owner: roster?.byId?.[task?.ownerBotId]?.name ?? null, - ownerKind: 'bot', - } -} - // A column takes a card dropped on it, and the board's own `onMove` decides whether that move is // allowed — the drop is a request, never a write from the client (#1326). const dropProps = (column, { dragging, onMove, onDrag }) => ({ @@ -107,21 +94,6 @@ const dropProps = (column, { dragging, onMove, onDrag }) => ({ }, }) -const taskCardNode = (task, { roster, onMove, onDrag }) => h(BoardCard, { - key: task.id, - card: taskCardModel(task, roster), - skin: TASK_SKIN, - attrs: { 'data-task': task.id }, - idText: taskShortId(task.id), - topExtra: task.priority && task.priority !== 'normal' - ? h('span', { className: 'bt-board-priority', 'data-priority': task.priority }, priorityLabel(task.priority)) - : null, - draggable: Boolean(onMove), - dragValue: task.id, - onDragStart: () => onDrag?.(task.id), - onDragEnd: () => onDrag?.(null), -}) - // Simplified task column renderer for degraded states (no GitHub snapshot). function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { const columns = { 'not-started': [], 'in-progress': [], 'done': [] } @@ -147,7 +119,14 @@ function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onD h('span', { className: 'bt-board-count' }, String(columns[column].length))), columns[column].length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) - : columns[column].map(task => taskCardNode(task, { roster, onMove, onDrag }))))) + : columns[column].map(task => h(TaskBoardCard, { + key: task.id, + task, + roster, + movable: canMoveTask(roster, task), + onMove, + onDrag + }))))) } export function duration(ms) { @@ -500,7 +479,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) : allCards(column.id).map(item => isTask(item) - ? taskCardNode(item, { roster, onMove, onDrag }) + ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) } @@ -536,7 +515,7 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry, filter = 'all' }) { +function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { const state = entry?.state ?? 'fresh' const source = repoSourceText(entry?.repoSource) return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, @@ -546,7 +525,7 @@ function DeliveryRepoBlock({ entry, filter = 'all' }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, filter })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag })) } export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { @@ -625,7 +604,7 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks entries === null ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, filter })))) + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, tasks, roster, dragging, onMove, onDrag, filter })))) } export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { From 6c27e52e4538a1928ed6fe39608c1d5907f4f541 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:43:19 +0000 Subject: [PATCH 4/5] Fix task duplication in multi-repo: each task appears once (#1326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-repo unified board now merges all successful repo columns into one shared DeliveryColumns with tasks appearing exactly once. Failed repos show as individual error blocks. multiRepoView helper: - Merges successful repos into one view with 3 workflow columns - Tasks passed once to merged DeliveryColumns, not to each repo block - Failed repos still render as DeliveryRepoBlock for error messages Card repo fallback: - DeliveryCard now uses card.repo when repo param is null - Ensures multi-repo cards keep their repo identity for data-key Tests added: - Multi-repo with tasks: asserts each task id appears exactly once - Start/Complete from unified board: verifies interactive buttons work through KanbanDialog → UnifiedBoard → DeliveryView path Updated repoSample fixture to use 3 workflow columns (not-started, in-progress, done) instead of 5 legacy columns. All tests green (1879 pass, 0 fail). Co-authored-by: Danyang Chen --- core/client.js | 38 +- core/src/client/delivery-view.js | 40 +- core/test/delivery-view.test.js | 66 +- core/test/kanban.test.js | 34 + patches/ds-bot/0008-client-bundle.patch | 1243 +++++++++-------- .../ds-bot/files/src/client/delivery-view.js | 40 +- .../ds-bot/files/test/delivery-view.test.js | 66 +- patches/ds-bot/files/test/kanban.test.js | 34 + 8 files changed, 903 insertions(+), 658 deletions(-) diff --git a/core/client.js b/core/client.js index b551132d0..47d4eaa8d 100644 --- a/core/client.js +++ b/core/client.js @@ -8867,7 +8867,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter (0, import_react16.createElement)("span", null, t(column.label)), (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) ), - allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) + allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) )) ); } @@ -8910,6 +8910,36 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null ))); } +function multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) { + const successful = (entries2 ?? []).filter((entry) => entry?.columns !== void 0); + const failed = (entries2 ?? []).filter((entry) => entry?.columns === void 0); + const merged = { columns: { "not-started": [], "in-progress": [], "done": [] } }; + for (const entry of successful) { + for (const [column, cards] of Object.entries(entry.columns)) { + if (merged.columns[column]) { + merged.columns[column].push(...cards); + } + } + } + return (0, import_react16.createElement)( + "div", + { className: "bt-del-multi" }, + // Successful repos: one shared column set with all cards and tasks once + successful.length > 0 ? (0, import_react16.createElement)(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) : null, + // Failed repos: show error blocks + failed.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { + key: entry.repo ?? "unknown", + entry, + tasks: [], + // Don't pass tasks to failed repo blocks + roster, + dragging, + onMove, + onDrag, + filter + })) + ); +} function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { const state = entry?.state ?? "fresh"; const source = repoSourceText(entry?.repoSource); @@ -9005,11 +9035,7 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], // A workflow's own columns come first: a card that names a workflow is drawn there and not in // the five columns below, which keep every card that names none (#1272 step 3). entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, - entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( - "div", - { className: "bt-del-repos" }, - entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, tasks, roster, dragging, onMove, onDrag, filter })) - ) + entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) ); } function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index d25ecadc5..0376cc92b 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -480,7 +480,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter : allCards(column.id).map(item => isTask(item) ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) + : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, @@ -513,6 +513,41 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { : null))) } +// #1326: Multi-repo unified board — merge successful repos into one shared column set with tasks +// appearing once, and show failed repos as error blocks. +function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag }) { + const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) + const failed = (entries ?? []).filter(entry => entry?.columns === undefined) + + // Merge all successful repo columns into one view, keeping only the workflow columns + const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } + for (const entry of successful) { + for (const [column, cards] of Object.entries(entry.columns)) { + // Only merge columns that exist in the workflow column set + if (merged.columns[column]) { + merged.columns[column].push(...cards) + } + } + } + + return h('div', { className: 'bt-del-multi' }, + // Successful repos: one shared column set with all cards and tasks once + successful.length > 0 + ? h(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) + : null, + // Failed repos: show error blocks + failed.map(entry => h(DeliveryRepoBlock, { + key: entry.repo ?? 'unknown', + entry, + tasks: [], // Don't pass tasks to failed repo blocks + roster, + dragging, + onMove, + onDrag, + filter + }))) +} + // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { @@ -603,8 +638,7 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks : null, entries === null ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) - : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, tasks, roster, dragging, onMove, onDrag, filter })))) + : multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag })) } export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index 544d4faa8..9932ec2cc 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -649,8 +649,9 @@ test('a host that cannot name a repository says so, and does not send the person const repoSample = (repo, extra = {}) => ({ repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', columns: { - raw: [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], - ready: [], working: [], review: [], 'done-today': [], + 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10`, repo }], + 'in-progress': [], + 'done': [], }, metrics: { dayStart: '2026-10-24T16:00:00.000Z', @@ -728,7 +729,7 @@ test('more than one repository draws one block each, one repository draws none ( const singleView = repoSample('acme/one') const one = DeliveryBoard({ view: { ...singleView, repos: [singleView] } }) assert.equal(walk(one).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 3, 'single-repo through repos[] uses workflow columns') const oneHead = walk(one).find(node => node.props?.className === 'bt-del-repo') assert.equal(oneHead.props['data-repo'], 'acme/one') assert.equal(oneHead.props['data-repos'], undefined) @@ -737,17 +738,17 @@ test('more than one repository draws one block each, one repository draws none ( const board = DeliveryBoard({ view: { state: 'fresh', repo: null, repoSource: null, asOf: '2026-10-25T04:00:00Z', repos: [repoSample('acme/one'), repoSample('acme/two')] }, }) + // #1326: Successful repos merge into one shared column set, no per-repo blocks const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.deepEqual(blocks.map(node => [node.props['data-repo'], node.props['data-state']]), - [['acme/one', 'fresh'], ['acme/two', 'fresh']]) - for (const block of blocks) { - assert.equal(walk(block).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - const card = walk(block).find(node => node.type === 'article') - // The same issue number in two repositories is two cards with two identities. - assert.equal(card.props['data-key'], `${block.props['data-repo']}#10`) - } - // Each block's head names the repository and where its name came from; the shared header counts. - assert.match(text(blocks[1]), /acme\/two\s+· from config/) + assert.equal(blocks.length, 0, 'no repo blocks for successful repos — all cards in one shared column set') + const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) + assert.equal(columns.length, 3, 'one shared column set with 3 workflow columns') + const cards = walk(board).filter(node => node.type === 'article') + // Both repos' cards appear in the shared columns; each card keeps its repo identity + const keys = cards.map(card => card.props['data-key']).sort() + assert.ok(keys.includes('acme/one#10'), 'acme/one card present') + assert.ok(keys.includes('acme/two#10'), 'acme/two card present') + // The shared header counts repositories const head = walk(board).find(node => node.props?.className === 'bt-del-repo') assert.equal(head.props['data-repos'], '2') assert.equal(head.props['data-repo'], undefined) @@ -767,16 +768,45 @@ test('a failed repository keeps its block, and the readable repository still dra asOf: '2026-10-25T04:00:00Z', }, }) + // #1326: Successful repos merge into shared columns; failed repos shown as error blocks const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 2, 'a failed repository does not take the board with it') - assert.equal(blocks[1].props['data-state'], 'failed') - assert.equal(walk(blocks[0]).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - assert.equal(walk(blocks[1]).some(node => node.type === 'section' && node.props?.['data-column']), false) - assert.match(text(blocks[1]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + assert.equal(blocks.length, 1, 'only the failed repo has a block') + assert.equal(blocks[0].props['data-state'], 'failed') + assert.equal(blocks[0].props['data-repo'], 'acme/two') + assert.equal(walk(blocks[0]).some(node => node.type === 'section' && node.props?.['data-column']), false) + assert.match(text(blocks[0]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + // The successful repo (acme/one) draws in shared columns outside the block + const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) + assert.equal(columns.length, 3, 'shared columns for successful repo') // The partial line names whose part it was, so one repository's failure is not read as another's. assert.match(text(board), /acme\/two · issues was not fetched this time/) }) +test('multi-repo with tasks: each task appears exactly once (#1326)', () => { + const task1 = { id: 'task-abc123', title: 'First task', status: 'open', ownerBotId: 'bot-one' } + const task2 = { id: 'task-def456', title: 'Second task', status: 'working', ownerBotId: 'bot-one' } + const roster = { byId: { 'bot-one': { id: 'bot-one', name: 'TestBot' } } } + const board = DeliveryBoard({ + view: { + state: 'fresh', + repo: null, + repoSource: null, + asOf: '2026-10-27T05:00:00Z', + repos: [repoSample('acme/one'), repoSample('acme/two')] + }, + tasks: [task1, task2], + roster, + }) + // Each task should appear exactly once, not duplicated per repo + const taskCards = walk(board).filter(node => node.props?.['data-task']) + assert.equal(taskCards.length, 2, 'exactly 2 task cards, not duplicated') + const taskIds = taskCards.map(card => card.props['data-task']) + assert.deepEqual(taskIds.sort(), ['task-abc123', 'task-def456'].sort()) + // GitHub cards from both repos should also be present + const githubCards = walk(board).filter(node => node.props?.['data-kind']) + assert.ok(githubCards.length > 0, 'GitHub cards from repos are present') +}) + test('no token names the GitHub side, never an empty board (#1211, #1265)', () => { const error = { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null } const board = DeliveryBoard({ view: { state: 'failed', repo: null, repos: [], error, retryAt: Date.now() + 10_000 }, onRetry: () => {} }) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index 6900c62e3..20bb087ee 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -216,6 +216,40 @@ test('a delivery read with no token still shows task cards on the one board (#12 assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) +test('Start and Complete fire from the unified board path (#1326)', () => { + const moved = [] + const openTask = task('task-open123', 'open', { title: 'Start me', ownerBotId: 's' }) + const workingTask = task('task-working456', 'working', { title: 'Complete me', ownerBotId: 's' }) + const actions = { + closeBoard: () => {}, + boardClose: () => {}, + taskUpdate: async ({ id, column }) => { moved.push([id, column]); return { ok: true } }, + deliveryBoard: async () => ({ ok: false, reason: 'no-github' }), + } + states.length = 0 + // KanbanDialog states: scope, dragging, error, draft + // useBoardTasks states: own, groupTasks, archive + states.push({ kind: 'bot', botId: 's' }, null, '', '', [openTask, workingTask], [], []) + const boardView = expand(KanbanDialog({ roster, actions })) + + // Find Start button on open task and click it + const cards = all(boardView).filter(node => node.props?.['data-task']) + const openCard = cards.find(card => card.props['data-task'] === 'task-open123') + assert.ok(openCard, 'open task card found') + const startButton = all(openCard).find(node => node.type === 'button' && textOf(node) === 'Start') + assert.ok(startButton, 'Start button found on open task') + startButton.props.onClick() + assert.deepEqual(moved[0], ['task-open123', 'in-progress'], 'Start moved task to in-progress') + + // Find Complete button on working task and click it + const workingCard = cards.find(card => card.props['data-task'] === 'task-working456') + assert.ok(workingCard, 'working task card found') + const completeButton = all(workingCard).find(node => node.type === 'button' && textOf(node) === 'Complete') + assert.ok(completeButton, 'Complete button found on working task') + completeButton.props.onClick() + assert.deepEqual(moved[1], ['task-working456', 'done'], 'Complete moved task to done') +}) + // #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished // cards — a card a member just finished has an old `createdAt`, so ordering by it would hide exactly // the card they are looking for. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 2b6e8d126..aff5e4f8b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1866,7 +1866,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,7157 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,7183 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -7362,9 +7362,7 @@ diff --git a/client.js b/client.js + { 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) }) + ); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ } + if (reply.kind === "lookup") { + return (0, import_react9.createElement)( + "div", @@ -7400,7 +7398,9 @@ diff --git a/client.js b/client.js + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react9.createElement)(RemoteMessage, { @@ -8744,12 +8744,7 @@ diff --git a/client.js b/client.js - const members = steps.length ? steps : [current]; - if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; - return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -+function 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 }; - } +-} -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(); @@ -8800,6 +8795,28 @@ diff --git a/client.js b/client.js - ); - }) - ); ++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 }; + } +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +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} @@ -8888,21 +8905,39 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); -+ return () => { + return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); -+ }; + }; +- }, [open]); + }, []); + return tasks; } --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; +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { - close(); +- return; +- } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -9032,8 +9067,13 @@ diff --git a/client.js b/client.js + place: placeOf(roster, roomId), + chat: roomId ?? bot.id, + ...people(roster, bot.id) -+ }); -+ }; + }); + }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); + 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(); @@ -9073,11 +9113,7 @@ diff --git a/client.js b/client.js + chat: askedIn2 ?? chatOfTask(task), + task, + ...people(roster, task.workBotId ?? bot.id) - }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); ++ }; + if (task.status === "failed") { + if (fresh(task.updatedAt)) needs.push({ ...card, status: "failed", reason: lastWhy(task) }); + } else if (task.status === "result-ready") needs.push({ ...card, status: "result", reason: task.result?.summary ?? "" }); @@ -9159,7 +9195,31 @@ diff --git a/client.js b/client.js + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); -+ }; + }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-result" }, @@ -9178,34 +9238,21 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); - } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -- }; -- }, [open]); ++ } + }) : 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; + redraw((count) => count + 1); + } }, t("Send back")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) -+ ); + ); } --function 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(); +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react13.useState)(0); + const question = card.kind === "question"; @@ -9221,26 +9268,11 @@ diff --git a/client.js b/client.js + const selected = multi ? slot.picked.filter((label) => label !== "") : []; + if (selected.length === 0 && custom === "") return; + onAnswer(card, { selected, custom }); - return; - } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); ++ return; ++ } + if (custom === "") return; + onAnswer(card, custom); - }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -- }); -- }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); ++ }; + const choose = (label) => { + if (busy) return; + if (!multi) { @@ -9249,31 +9281,7 @@ diff --git a/client.js b/client.js + } + slot.picked = slot.picked.includes(label) ? slot.picked.filter((item) => item !== label) : [...slot.picked, label]; + redraw((count) => count + 1); - }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( -- "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null ++ }; + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-answer" }, @@ -9305,35 +9313,6 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ); } --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 InboxRow({ wide, useRoster, useWork, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const work = useWork ? useWork((value) => value) : {}; -+ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; -+ const tasks = useTasks(actions); -+ const count = needsCount(roster, work, tasks); -+ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); -+ return (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row bt-inbox-row", -+ "aria-label": label, -+ title: wide ? void 0 : label, -+ "aria-haspopup": "dialog", -+ "aria-expanded": open, -+ onClick: () => actions.openInbox() -+ }, -+ (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), -+ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, -+ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); - } -function useFileMentions({ node, openFile, fileMentions, useTurnData }) { - const tail = useTurnData?.("turn-tail"); - const location2 = node.location; @@ -9343,12 +9322,10 @@ diff --git a/client.js b/client.js - if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; - return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; - }, [fileMentions, openFile, seq, tail, turn]); -+function Status({ status }) { -+ const { label, tone, Icon } = STATUSES[status]; -+ return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); - } +-} -function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -- const roster = useRoster((value) => value); ++function InboxRow({ wide, useRoster, useWork, useUi, actions }) { + const roster = useRoster((value) => value); - const data = node.data; - const steps = useTurnSteps(String(data.turn)); - const contexts = useTurnContext(String(data.turn)); @@ -9380,33 +9357,96 @@ diff --git a/client.js b/client.js - }), - reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, - texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -+function whenText(card, now) { -+ if (card.status === "running" || card.status === "task") { -+ return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); -+ } -+ return typeof card.at === "number" ? relativeShort(card.at) : ""; -+} -+function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { -+ const who = card.bot ? card.bot.name : card.room?.name ?? ""; -+ const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; -+ const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); -+ const answerable = card.kind === "approval" && card.approval?.answerable === true; -+ const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; -+ const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; -+ const target = card.kind === "task" ? card.task?.target?.href : void 0; -+ const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; -+ const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; ++ const work = useWork ? useWork((value) => value) : {}; ++ const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; ++ const tasks = useTasks(actions); ++ const count = needsCount(roster, work, tasks); ++ const label = count > 0 ? t("Inbox, {count} need you", { count }) : t("Inbox"); + return (0, import_react13.createElement)( -+ "div", -+ { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, -+ (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), -+ (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-copy" }, -+ (0, import_react13.createElement)( -+ "span", -+ { className: "bt-inbox-top" }, -+ (0, import_react13.createElement)(Status, { status: card.status }), ++ "button", ++ { ++ type: "button", ++ className: "bt-row bt-inbox-row", ++ "aria-label": label, ++ title: wide ? void 0 : label, ++ "aria-haspopup": "dialog", ++ "aria-expanded": open, ++ onClick: () => actions.openInbox() ++ }, ++ (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), ++ wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, ++ count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null + ); + } +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++function Status({ status }) { ++ const { label, tone, Icon } = STATUSES[status]; ++ return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( ++function whenText(card, now) { ++ if (card.status === "running" || card.status === "task") { ++ return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); ++ } ++ return typeof card.at === "number" ? relativeShort(card.at) : ""; ++} ++function InboxCard({ card, roster, now, busy, error, onAnswer, onOpen }) { ++ const who = card.bot ? card.bot.name : card.room?.name ?? ""; ++ const produced = card.kind === "task" && card.status === "result" ? [card.bot?.model] : []; ++ const meta = [who, ...produced, card.place, whenText(card, now)].filter(Boolean).join(" · "); ++ const answerable = card.kind === "approval" && card.approval?.answerable === true; ++ const evidence = card.kind === "task" && card.status === "result" ? card.task?.result?.links ?? [] : []; ++ const sentBack = card.kind === "task" && card.status === "task" ? card.task?.followUp?.note : void 0; ++ const target = card.kind === "task" ? card.task?.target?.href : void 0; ++ const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; ++ const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; ++ return (0, import_react13.createElement)( + "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( ++ { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, ++ (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), ++ (0, import_react13.createElement)( + "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ { className: "bt-inbox-copy" }, ++ (0, import_react13.createElement)( ++ "span", ++ { className: "bt-inbox-top" }, ++ (0, import_react13.createElement)(Status, { status: card.status }), + (0, import_react13.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) + ), + (0, import_react13.createElement)("span", { className: "bt-inbox-title", title: card.title }, card.title), @@ -9435,7 +9475,8 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react13.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -9446,9 +9487,13 @@ diff --git a/client.js b/client.js + ) ); } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( +-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 useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9456,7 +9501,16 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ms]); + return now; -+} + } +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9494,18 +9548,13 @@ diff --git a/client.js b/client.js + actions.openSession(card.chat); + }; + return (0, import_react13.createElement)( -+ "div", + "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-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react13.createElement)( - "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) ++ "div", + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react13.createElement)( + "div", @@ -9539,11 +9588,18 @@ diff --git a/client.js b/client.js + onOpen: open + }))) + )) - ) -- ); ++ ) + ) -+ ); -+} + ); + } +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-} +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +- } +- return null; +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9606,7 +9662,27 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +- }); +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; +} @@ -9675,7 +9751,42 @@ diff --git a/client.js b/client.js + linked: row.linked ?? null, + sha: row.sha ?? null, + url: row.url ?? null -+ }; + }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; +- }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (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 +} +function taskCard(task) { + const key = taskCardKey(task?.id); @@ -9767,7 +9878,36 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null -+ ), + ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; +- } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -9778,8 +9918,33 @@ diff --git a/client.js b/client.js + card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, + footer, + actions -+ ); -+} + ); + } +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -9809,17 +9974,7 @@ diff --git a/client.js b/client.js + const denominator = Number.isFinite(counted) && counted > 0 ? counted : Number.isFinite(total) && total > 0 ? total : null; + if (!Number.isFinite(failed) || denominator === null) return null; + return Math.min(1, Math.max(0, failed / denominator)); - } -- 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 -- )); ++ } + const value = day[key]; + return Number.isFinite(value) ? value : null; +} @@ -9865,7 +10020,36 @@ diff --git a/client.js b/client.js + value + }; }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); + const segments = []; + let run = null; + for (const dot of dots) { @@ -9873,12 +10057,24 @@ diff --git a/client.js b/client.js + if (run) { + segments.push(run); + run = null; -+ } + } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + continue; -+ } + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + if (!run) run = { dots: [] }; + run.dots.push(dot); -+ } + } + if (run) segments.push(run); + return { + width, @@ -9893,22 +10089,8 @@ diff --git a/client.js b/client.js + })) + }; } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) -- ); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) +-function 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 DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -9941,35 +10123,35 @@ diff --git a/client.js b/client.js + cy: dot.y, + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) - ); ++ ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); + const fewest = short.length ? Math.min(...short) : 0; + const head = (0, import_react15.createElement)( - "div", -- { 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 ++ "div", + { className: "bt-del-dm-head", "data-flybot": "delivery-metrics-head" }, + (0, import_react15.createElement)("span", { className: "bt-del-dm-label" }, ""), + (0, import_react15.createElement)("span", { className: "bt-del-dm-chart-head" }, ""), + (0, import_react15.createElement)("span", { className: "bt-del-dm-value" }, t("Today")), + (0, import_react15.createElement)("span", { className: "bt-del-dm-value bt-del-dm-week" }, t("7 days")) - ); --} --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)( ++ ); + const body = rows.map((row, index) => { + const key = CHART_KEYS.includes(row?.key) ? row.key : null; + const chart = days && key ? (0, import_react15.createElement)(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) : null; @@ -9983,20 +10165,30 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") + ]; + return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); -+ }); + }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +-} +-function Nothing() { +- return null; + return (0, import_react15.createElement)( - "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ "div", + { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, + head, + body, + days ? null : (0, import_react15.createElement)("p", { className: "bt-del-chart-empty", "data-flybot": "delivery-chart-empty" }, t("No 7-day data yet.")), + days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null - ); ++ ); } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +var DELIVERY_CHART_CSS = ` +.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} +.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} @@ -10019,9 +10211,11 @@ diff --git a/client.js b/client.js + .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} + .bt-del-dm-value{min-width:0} } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +`; + +// src/client/delivery-view.js @@ -10036,9 +10230,41 @@ diff --git a/client.js b/client.js + if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { + return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); } -- return 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 POLL_MS3; -+} + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +var DELIVERY_COLUMNS = [ + { id: "not-started", label: "Not started" }, + { id: "in-progress", label: "In progress" }, @@ -10063,55 +10289,7 @@ diff --git a/client.js b/client.js + // The checks could not be read: that is not "no verdict", and saying so blamed the pull request + // for a permission the instance is missing (#1211). + "checks-unknown": "Checks unknown" - }; --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); -- return () => { -- alive.current = false; -- }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (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); ++}; +var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; +function taskColumnOf(task) { + const col = columnOf(task); @@ -10129,8 +10307,7 @@ diff --git a/client.js b/client.js + const id = event.dataTransfer?.getData("text/plain") ?? ""; + if (id) onMove?.(id, column); + onDrag?.(null); - } -- return (0, import_react6.createElement)( ++ } +}); +function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { + const columns = { "not-started": [], "in-progress": [], "done": [] }; @@ -10144,42 +10321,7 @@ diff --git a/client.js b/client.js + "done": t("Done") + }; + return (0, import_react16.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 }) ++ "div", + { className: "bt-board-columns" }, + ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( + "section", @@ -10207,76 +10349,12 @@ diff --git a/client.js b/client.js + )) ); } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -- } -- 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 Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-} +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +function duration(ms) { + if (!Number.isFinite(ms) || ms < 0) return "—"; + const hours = ms / 36e5; @@ -10304,26 +10382,7 @@ diff --git a/client.js b/client.js + if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); + if (kind === "rate-limit") { + return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); - } --} --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 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; ++ } + if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); + if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); + if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); @@ -10345,7 +10404,36 @@ diff --git a/client.js b/client.js + const at = Date.parse(iso ?? ""); + if (!Number.isFinite(at)) return "—"; + return new Date(at).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit" }); -+}; + }; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +var timeOf = (iso) => { + const at = Date.parse(iso ?? ""); + if (!Number.isFinite(at)) return "—"; @@ -10373,11 +10461,7 @@ diff --git a/client.js b/client.js + if (row.today !== void 0) return { key: row.key, label: row.label, today: row.today, week: row.week }; + const format = row.format ?? count; + return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) }; - }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; --} --function Nothing() { -- return null; ++ }); + return { + dayStart: snapshot?.dayStart ?? null, + rows, @@ -10392,17 +10476,7 @@ diff --git a/client.js b/client.js + }, + open: snapshot?.open ?? {} + }; - } --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 createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { + let stopped = false; + let timer = null; @@ -10436,7 +10510,12 @@ diff --git a/client.js b/client.js + last = null; + onView({ state: "failed", error: { message: String(error?.message ?? error), kind: "network", endpoint: null, status: null, at: null } }); + schedule(); -+ } + } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; + }; + return { + start: () => { @@ -10459,14 +10538,25 @@ diff --git a/client.js b/client.js + stopped = true; + if (timer !== null) clearTimer(timer); + timer = 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; + } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; ++ }; ++} +function repoSourceText(source) { + if (source === "config") return t("from config"); + if (source === "remote") return t("from remote"); @@ -10502,18 +10592,12 @@ diff --git a/client.js b/client.js + const where = report.repo === null && row.repo ? `${row.repo} ` : ""; + lines.push(`${where}${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); } -- return next === void 0 ? void 0 : turns[next].first; + if (report.error) lines.push(String(report.error)); + return lines.join("\n"); } --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 workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function Diagnose({ onProbe }) { + const [report, setReport] = (0, import_react16.useState)(null); + const [busy, setBusy] = (0, import_react16.useState)(false); @@ -10546,29 +10630,13 @@ diff --git a/client.js b/client.js + ) : null + ); } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); +function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { + const payload = view ?? snapshot; + if (!payload) return null; @@ -10623,10 +10691,23 @@ diff --git a/client.js b/client.js + onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, + onProbe ? (0, import_react16.createElement)(Diagnose, { onProbe }) : null + ) - ); ++ ); } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-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 }); +var BOARD_FILTERS = ["all", "review", "done-today"]; +function BoardFilters({ filter, onFilter }) { + return (0, import_react16.createElement)( @@ -10641,63 +10722,7 @@ diff --git a/client.js b/client.js + onClick: () => onFilter(id) + }, id === "all" ? t("All") : id === "review" ? t("Review") : t("Done today"))) + ); - } --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) }; ++} +var stageOf = (card, column) => card?.stage ?? column; +var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; +function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { @@ -10708,7 +10733,7 @@ diff --git a/client.js b/client.js + for (const task of filter === "all" ? tasks ?? [] : []) { + const col = taskColumnOf(task); + if (tasksByColumn[col]) tasksByColumn[col].push(task); - } ++ } + const allCards = (id) => { + const delivery = deliveryCards(id); + const taskList = tasksByColumn[id] ?? []; @@ -10733,13 +10758,10 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("span", null, t(column.label)), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(allCards(column.id).length)) + ), -+ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${item.kind}-${item.number}`, card: item, repo })) ++ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) + )) + ); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++} +function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { + const list = Array.isArray(sections) ? sections : []; + if (list.length === 0) return null; @@ -10778,50 +10800,16 @@ diff --git a/client.js b/client.js + })), + (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null + ))); - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); -+function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { -+ const state = entry?.state ?? "fresh"; -+ const source = repoSourceText(entry?.repoSource); -+ return (0, import_react16.createElement)( -+ "section", -+ { className: "bt-del-repo-block", "data-repo": entry?.repo ?? void 0, "data-state": state }, -+ (0, import_react16.createElement)( -+ "h3", -+ { className: "bt-del-repo-head" }, -+ entry?.repo ?? t("no repository — set delivery.repo"), -+ source === null ? "" : ` · ${source}` -+ ), -+ entry?.columns === void 0 ? (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-block-state", role: "status" }, -+ state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), -+ entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" -+ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) -+ ); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -- } ++} ++function multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) { ++ const successful = (entries2 ?? []).filter((entry) => entry?.columns !== void 0); ++ const failed = (entries2 ?? []).filter((entry) => entry?.columns === void 0); ++ const merged = { columns: { "not-started": [], "in-progress": [], "done": [] } }; ++ for (const entry of successful) { ++ for (const [column, cards] of Object.entries(entry.columns)) { ++ if (merged.columns[column]) { ++ merged.columns[column].push(...cards); + } - timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; - void tick(); @@ -10836,38 +10824,15 @@ diff --git a/client.js b/client.js - replies = JSON.parse(contentText(content)).replies ?? []; - } catch { - replies = []; -- } -+function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { -+ const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); -+ const state = payload.state ?? "fresh"; -+ const retryAt = Number(payload.retryAt); -+ const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; -+ const [now, setNow] = (0, import_react16.useState)(() => Date.now()); -+ (0, import_react16.useEffect)(() => { -+ if (!ticking) return void 0; -+ setNow(Date.now()); -+ const timer = setInterval(() => setNow(Date.now()), 1e3); -+ return () => clearInterval(timer); -+ }, [ticking, retryAt]); -+ const countdown = retryCountdown(payload.retryAt, now); -+ const [filter, setFilter] = (0, import_react16.useState)("all"); -+ const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; -+ const hasTasks = Array.isArray(tasks) && tasks.length > 0; -+ if (state === "idle") { -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "board", "data-state": state }, -+ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), -+ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, -+ message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) -+ ); + } } - 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", ++ return (0, import_react16.createElement)( + "div", - { className: "bt-group-replies" }, - replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; @@ -10922,6 +10887,68 @@ diff --git a/client.js b/client.js - const bot = roster.byId[id]; - if (!bot) return null; - return (0, import_react6.createElement)( ++ { className: "bt-del-multi" }, ++ // Successful repos: one shared column set with all cards and tasks once ++ successful.length > 0 ? (0, import_react16.createElement)(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) : null, ++ // Failed repos: show error blocks ++ failed.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { ++ key: entry.repo ?? "unknown", ++ entry, ++ tasks: [], ++ // Don't pass tasks to failed repo blocks ++ roster, ++ dragging, ++ onMove, ++ onDrag, ++ filter ++ })) ++ ); ++} ++function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { ++ const state = entry?.state ?? "fresh"; ++ const source = repoSourceText(entry?.repoSource); ++ return (0, import_react16.createElement)( ++ "section", ++ { className: "bt-del-repo-block", "data-repo": entry?.repo ?? void 0, "data-state": state }, ++ (0, import_react16.createElement)( ++ "h3", ++ { className: "bt-del-repo-head" }, ++ entry?.repo ?? t("no repository — set delivery.repo"), ++ source === null ? "" : ` · ${source}` ++ ), ++ entry?.columns === void 0 ? (0, import_react16.createElement)( ++ "p", ++ { className: "bt-del-block-state", role: "status" }, ++ state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), ++ entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" ++ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) ++ ); ++} ++function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { ++ const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); ++ const state = payload.state ?? "fresh"; ++ const retryAt = Number(payload.retryAt); ++ const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; ++ const [now, setNow] = (0, import_react16.useState)(() => Date.now()); ++ (0, import_react16.useEffect)(() => { ++ if (!ticking) return void 0; ++ setNow(Date.now()); ++ const timer = setInterval(() => setNow(Date.now()), 1e3); ++ return () => clearInterval(timer); ++ }, [ticking, retryAt]); ++ const countdown = retryCountdown(payload.retryAt, now); ++ const [filter, setFilter] = (0, import_react16.useState)("all"); ++ const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; ++ const hasTasks = Array.isArray(tasks) && tasks.length > 0; ++ if (state === "idle") { ++ return (0, import_react16.createElement)( ++ "div", ++ { className: "bt-del-body", "data-view": "board", "data-state": state }, ++ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), ++ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, ++ message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) ++ ); ++ } + if (state === "loading") { + return (0, import_react16.createElement)( + "div", @@ -10977,11 +11004,7 @@ diff --git a/client.js b/client.js + // A workflow's own columns come first: a card that names a workflow is drawn there and not in + // the five columns below, which keep every card that names none (#1272 step 3). + entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null, filter }) : null, -+ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-repos" }, -+ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry, tasks, roster, dragging, onMove, onDrag, filter })) -+ ) ++ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) + ); +} +function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index d25ecadc5..0376cc92b 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -480,7 +480,7 @@ function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter : allCards(column.id).map(item => isTask(item) ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? ''}-${item.kind}-${item.number}`, card: item, repo }))))) + : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) } // The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, @@ -513,6 +513,41 @@ function DeliveryWorkflowSections({ sections, repo = null, filter = 'all' }) { : null))) } +// #1326: Multi-repo unified board — merge successful repos into one shared column set with tasks +// appearing once, and show failed repos as error blocks. +function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag }) { + const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) + const failed = (entries ?? []).filter(entry => entry?.columns === undefined) + + // Merge all successful repo columns into one view, keeping only the workflow columns + const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } + for (const entry of successful) { + for (const [column, cards] of Object.entries(entry.columns)) { + // Only merge columns that exist in the workflow column set + if (merged.columns[column]) { + merged.columns[column].push(...cards) + } + } + } + + return h('div', { className: 'bt-del-multi' }, + // Successful repos: one shared column set with all cards and tasks once + successful.length > 0 + ? h(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) + : null, + // Failed repos: show error blocks + failed.map(entry => h(DeliveryRepoBlock, { + key: entry.repo ?? 'unknown', + entry, + tasks: [], // Don't pass tasks to failed repo blocks + roster, + dragging, + onMove, + onDrag, + filter + }))) +} + // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside // it, which is why one failure no longer empties the board (#1265, #1211). function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { @@ -603,8 +638,7 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks : null, entries === null ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) - : h('div', { className: 'bt-del-repos' }, - entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry, tasks, roster, dragging, onMove, onDrag, filter })))) + : multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag })) } export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index 544d4faa8..9932ec2cc 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -649,8 +649,9 @@ test('a host that cannot name a repository says so, and does not send the person const repoSample = (repo, extra = {}) => ({ repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', columns: { - raw: [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], - ready: [], working: [], review: [], 'done-today': [], + 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10`, repo }], + 'in-progress': [], + 'done': [], }, metrics: { dayStart: '2026-10-24T16:00:00.000Z', @@ -728,7 +729,7 @@ test('more than one repository draws one block each, one repository draws none ( const singleView = repoSample('acme/one') const one = DeliveryBoard({ view: { ...singleView, repos: [singleView] } }) assert.equal(walk(one).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 3, 'single-repo through repos[] uses workflow columns') const oneHead = walk(one).find(node => node.props?.className === 'bt-del-repo') assert.equal(oneHead.props['data-repo'], 'acme/one') assert.equal(oneHead.props['data-repos'], undefined) @@ -737,17 +738,17 @@ test('more than one repository draws one block each, one repository draws none ( const board = DeliveryBoard({ view: { state: 'fresh', repo: null, repoSource: null, asOf: '2026-10-25T04:00:00Z', repos: [repoSample('acme/one'), repoSample('acme/two')] }, }) + // #1326: Successful repos merge into one shared column set, no per-repo blocks const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.deepEqual(blocks.map(node => [node.props['data-repo'], node.props['data-state']]), - [['acme/one', 'fresh'], ['acme/two', 'fresh']]) - for (const block of blocks) { - assert.equal(walk(block).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - const card = walk(block).find(node => node.type === 'article') - // The same issue number in two repositories is two cards with two identities. - assert.equal(card.props['data-key'], `${block.props['data-repo']}#10`) - } - // Each block's head names the repository and where its name came from; the shared header counts. - assert.match(text(blocks[1]), /acme\/two\s+· from config/) + assert.equal(blocks.length, 0, 'no repo blocks for successful repos — all cards in one shared column set') + const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) + assert.equal(columns.length, 3, 'one shared column set with 3 workflow columns') + const cards = walk(board).filter(node => node.type === 'article') + // Both repos' cards appear in the shared columns; each card keeps its repo identity + const keys = cards.map(card => card.props['data-key']).sort() + assert.ok(keys.includes('acme/one#10'), 'acme/one card present') + assert.ok(keys.includes('acme/two#10'), 'acme/two card present') + // The shared header counts repositories const head = walk(board).find(node => node.props?.className === 'bt-del-repo') assert.equal(head.props['data-repos'], '2') assert.equal(head.props['data-repo'], undefined) @@ -767,16 +768,45 @@ test('a failed repository keeps its block, and the readable repository still dra asOf: '2026-10-25T04:00:00Z', }, }) + // #1326: Successful repos merge into shared columns; failed repos shown as error blocks const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 2, 'a failed repository does not take the board with it') - assert.equal(blocks[1].props['data-state'], 'failed') - assert.equal(walk(blocks[0]).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - assert.equal(walk(blocks[1]).some(node => node.type === 'section' && node.props?.['data-column']), false) - assert.match(text(blocks[1]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + assert.equal(blocks.length, 1, 'only the failed repo has a block') + assert.equal(blocks[0].props['data-state'], 'failed') + assert.equal(blocks[0].props['data-repo'], 'acme/two') + assert.equal(walk(blocks[0]).some(node => node.type === 'section' && node.props?.['data-column']), false) + assert.match(text(blocks[0]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + // The successful repo (acme/one) draws in shared columns outside the block + const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) + assert.equal(columns.length, 3, 'shared columns for successful repo') // The partial line names whose part it was, so one repository's failure is not read as another's. assert.match(text(board), /acme\/two · issues was not fetched this time/) }) +test('multi-repo with tasks: each task appears exactly once (#1326)', () => { + const task1 = { id: 'task-abc123', title: 'First task', status: 'open', ownerBotId: 'bot-one' } + const task2 = { id: 'task-def456', title: 'Second task', status: 'working', ownerBotId: 'bot-one' } + const roster = { byId: { 'bot-one': { id: 'bot-one', name: 'TestBot' } } } + const board = DeliveryBoard({ + view: { + state: 'fresh', + repo: null, + repoSource: null, + asOf: '2026-10-27T05:00:00Z', + repos: [repoSample('acme/one'), repoSample('acme/two')] + }, + tasks: [task1, task2], + roster, + }) + // Each task should appear exactly once, not duplicated per repo + const taskCards = walk(board).filter(node => node.props?.['data-task']) + assert.equal(taskCards.length, 2, 'exactly 2 task cards, not duplicated') + const taskIds = taskCards.map(card => card.props['data-task']) + assert.deepEqual(taskIds.sort(), ['task-abc123', 'task-def456'].sort()) + // GitHub cards from both repos should also be present + const githubCards = walk(board).filter(node => node.props?.['data-kind']) + assert.ok(githubCards.length > 0, 'GitHub cards from repos are present') +}) + test('no token names the GitHub side, never an empty board (#1211, #1265)', () => { const error = { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null } const board = DeliveryBoard({ view: { state: 'failed', repo: null, repos: [], error, retryAt: Date.now() + 10_000 }, onRetry: () => {} }) diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index 6900c62e3..20bb087ee 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -216,6 +216,40 @@ test('a delivery read with no token still shows task cards on the one board (#12 assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) +test('Start and Complete fire from the unified board path (#1326)', () => { + const moved = [] + const openTask = task('task-open123', 'open', { title: 'Start me', ownerBotId: 's' }) + const workingTask = task('task-working456', 'working', { title: 'Complete me', ownerBotId: 's' }) + const actions = { + closeBoard: () => {}, + boardClose: () => {}, + taskUpdate: async ({ id, column }) => { moved.push([id, column]); return { ok: true } }, + deliveryBoard: async () => ({ ok: false, reason: 'no-github' }), + } + states.length = 0 + // KanbanDialog states: scope, dragging, error, draft + // useBoardTasks states: own, groupTasks, archive + states.push({ kind: 'bot', botId: 's' }, null, '', '', [openTask, workingTask], [], []) + const boardView = expand(KanbanDialog({ roster, actions })) + + // Find Start button on open task and click it + const cards = all(boardView).filter(node => node.props?.['data-task']) + const openCard = cards.find(card => card.props['data-task'] === 'task-open123') + assert.ok(openCard, 'open task card found') + const startButton = all(openCard).find(node => node.type === 'button' && textOf(node) === 'Start') + assert.ok(startButton, 'Start button found on open task') + startButton.props.onClick() + assert.deepEqual(moved[0], ['task-open123', 'in-progress'], 'Start moved task to in-progress') + + // Find Complete button on working task and click it + const workingCard = cards.find(card => card.props['data-task'] === 'task-working456') + assert.ok(workingCard, 'working task card found') + const completeButton = all(workingCard).find(node => node.type === 'button' && textOf(node) === 'Complete') + assert.ok(completeButton, 'Complete button found on working task') + completeButton.props.onClick() + assert.deepEqual(moved[1], ['task-working456', 'done'], 'Complete moved task to done') +}) + // #1252: the Done column is archive, not the board, and the slice has to be the *newest* finished // cards — a card a member just finished has an old `createdAt`, so ordering by it would hide exactly // the card they are looking for. From fb012a4b17eb7f43f47e73ae54a8b0c39f5d5a40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 05:51:53 +0000 Subject: [PATCH 5/5] Stamp entry.repo onto cards during merge to preserve identity (#1326) Cards from cardOf often omit the repo property (name lives on the repository entry). multiRepoView now stamps entry.repo onto each card while merging, so data-key stays unique across repos. Without this fix, two repos' issue #10 would both become data-key 'github:issue:10' instead of 'acme/one#10' and 'acme/two#10'. Fix: In multiRepoView merge loop, map each card to { ...card, repo: card.repo ?? entry.repo } before pushing to merged columns. Test: Updated repoSample to create cards WITHOUT repo property (only entry.repo set). The multi-repo test now verifies distinct data-keys for same-number issues from different repos. All tests green (1879 pass, 0 fail). Co-authored-by: Danyang Chen --- core/client.js | 2 +- core/src/client/delivery-view.js | 5 +++-- core/test/delivery-view.test.js | 2 +- patches/ds-bot/0008-client-bundle.patch | 2 +- patches/ds-bot/files/src/client/delivery-view.js | 5 +++-- patches/ds-bot/files/test/delivery-view.test.js | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/core/client.js b/core/client.js index 47d4eaa8d..5ad3ec779 100644 --- a/core/client.js +++ b/core/client.js @@ -8917,7 +8917,7 @@ function multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDr for (const entry of successful) { for (const [column, cards] of Object.entries(entry.columns)) { if (merged.columns[column]) { - merged.columns[column].push(...cards); + merged.columns[column].push(...cards.map((card) => ({ ...card, repo: card.repo ?? entry.repo }))); } } } diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 0376cc92b..5bc6c761a 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -519,13 +519,14 @@ function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDra const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) const failed = (entries ?? []).filter(entry => entry?.columns === undefined) - // Merge all successful repo columns into one view, keeping only the workflow columns + // Merge all successful repo columns into one view, keeping only the workflow columns. + // Stamp entry.repo onto each card so data-key stays unique across repos (#1326). const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } for (const entry of successful) { for (const [column, cards] of Object.entries(entry.columns)) { // Only merge columns that exist in the workflow column set if (merged.columns[column]) { - merged.columns[column].push(...cards) + merged.columns[column].push(...cards.map(card => ({ ...card, repo: card.repo ?? entry.repo }))) } } } diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index 9932ec2cc..6926a01ab 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -649,7 +649,7 @@ test('a host that cannot name a repository says so, and does not send the person const repoSample = (repo, extra = {}) => ({ repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', columns: { - 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10`, repo }], + 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], 'in-progress': [], 'done': [], }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index aff5e4f8b..e3a361516 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -10808,7 +10808,7 @@ diff --git a/client.js b/client.js + for (const entry of successful) { + for (const [column, cards] of Object.entries(entry.columns)) { + if (merged.columns[column]) { -+ merged.columns[column].push(...cards); ++ merged.columns[column].push(...cards.map((card) => ({ ...card, repo: card.repo ?? entry.repo }))); } - timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 0376cc92b..5bc6c761a 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -519,13 +519,14 @@ function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDra const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) const failed = (entries ?? []).filter(entry => entry?.columns === undefined) - // Merge all successful repo columns into one view, keeping only the workflow columns + // Merge all successful repo columns into one view, keeping only the workflow columns. + // Stamp entry.repo onto each card so data-key stays unique across repos (#1326). const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } for (const entry of successful) { for (const [column, cards] of Object.entries(entry.columns)) { // Only merge columns that exist in the workflow column set if (merged.columns[column]) { - merged.columns[column].push(...cards) + merged.columns[column].push(...cards.map(card => ({ ...card, repo: card.repo ?? entry.repo }))) } } } diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index 9932ec2cc..6926a01ab 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -649,7 +649,7 @@ test('a host that cannot name a repository says so, and does not send the person const repoSample = (repo, extra = {}) => ({ repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', columns: { - 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10`, repo }], + 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], 'in-progress': [], 'done': [], },