From 319aaf3f77b8591f738cf2a8715e536f71654490 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-2 Date: Thu, 29 Oct 2026 15:29:23 +0000 Subject: [PATCH 1/3] test(board): pin the read-only Project board (#1701) Red first: on 742e06cc this file is 2 pass / 8 fail. Two of the reds are behaviour, not a missing export: a project render falls into the default branch and draws another Project's cards, and the rail has no Project row. The rest pin the one project-to-members mapping, the three disjoint columns, the 24h delivered window and the read-only board. --- core/test/board-project.test.js | 276 ++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 core/test/board-project.test.js diff --git a/core/test/board-project.test.js b/core/test/board-project.test.js new file mode 100644 index 000000000..f1bdf378a --- /dev/null +++ b/core/test/board-project.test.js @@ -0,0 +1,276 @@ +// Project board, slice 1 (#1701): one read-only Project view over the task records. +// The project identity is the durable Project record (#1695): a Bot or a group names a +// project on its own record, the roster already carries `projectId`, and this file's +// board only narrows the one task read by that binding — no second store, no endpoint. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const kanbanPath = fileURLToPath(new URL('../src/client/kanban.js', import.meta.url)) +const source = readFileSync(kanbanPath, 'utf8') + +const bundle = await build({ + entryPoints: [kanbanPath], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +const module = { exports: {} } +const states = [] +const effects = [] +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, setInterval: () => 0, clearInterval: () => {}, Date, Promise, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial), () => {}], + useEffect: fn => { effects.push(fn) }, useCallback: fn => fn, useRef: current => ({ current }), + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +}) +const { KanbanDialog, boardRail, projectColumns, projectScope } = module.exports + +const expand = (node) => { + if (Array.isArray(node)) return node.flatMap(expand) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type({ ...node.props, children: node.children })) + return { ...node, children: node.children.map(expand) } +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.children, out) } + return out +} +const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? node.children.map(textOf).join('') : '') +const byClass = (tree, name) => all(tree).filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name)) + +// Two projects, each with one Bot and one group; two Bots and one group stay unbound. +const HOURS = 3_600_000 +const rosterWithProjects = () => { + const roster = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1, projectId: 'project-rocket' }, + { id: 's', name: 'Writer', ownerId: 'm1', createdBy: 'a', createdAt: 2, projectId: 'project-rocket' }, + { id: 'b', name: 'Other', ownerId: 'm1', createdBy: 'a', createdAt: 3, projectId: 'project-garden' }, + { id: 'c', name: 'Loose', ownerId: 'm1', createdAt: 4 }, + ], + rooms: [ + { id: 'war', name: 'War', admin: 'a', projectId: 'project-rocket', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + { id: 'garden', name: 'Garden', admin: 'a', projectId: 'project-garden', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + roster.byId = Object.fromEntries(roster.bots.map(bot => [bot.id, bot])) + roster.roomsById = Object.fromEntries(roster.rooms.map(room => [room.id, room])) + return roster +} +const unboundRoster = () => { + const roster = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1 }, + { id: 's', name: 'Writer', ownerId: 'm1', createdBy: 'a', createdAt: 2 }, + ], + rooms: [ + { id: 'war', name: 'War', admin: 'a', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + roster.byId = Object.fromEntries(roster.bots.map(bot => [bot.id, bot])) + roster.roomsById = Object.fromEntries(roster.rooms.map(room => [room.id, room])) + return roster +} +const projects = [ + { id: 'project-rocket', name: 'Rocket', status: 'active' }, + { id: 'project-garden', name: 'Garden', status: 'active' }, + { id: 'project-archive', name: 'Old', status: 'archived' }, +] + +const task = (id, status, extra) => ({ id, status, title: `Task ${id}`, ownerBotId: 'a', priority: 'normal', progress: 0, updatedAt: 1, ...extra }) + +// KanbanDialog's preset states: scope, dragging, error, draft, then useBoardTasks' +// own, groupTasks, archive, projects. +function dialog(roster, { scope, own = [], archive = [], projectList = projects, actions = {} } = {}) { + states.length = 0 + states.push(scope, null, '', '', own, [], archive, projectList) + return expand(KanbanDialog({ roster, actions: { closeBoard: () => {}, ...actions } })) +} +const cardsOf = tree => all(tree).filter(node => typeof node.props?.['data-task'] === 'string') +const projectRows = tree => all(tree).filter(node => node.props?.['data-kind'] === 'project') + +test('#1701: the project board splits in flight, waiting and delivered without counting a task twice', () => { + const now = 1_000 * HOURS + const list = [ + task('task-open', 'open'), + task('task-claimed', 'claimed'), + task('task-working', 'working'), + task('task-waiting', 'blocked', { waitingFor: 'input', inputRequest: { id: 'q1', question: 'Which one?' } }), + task('task-result', 'result-ready'), + task('task-shipped', 'accepted', { progress: 100, updatedAt: now - 23 * HOURS }), + task('task-old', 'accepted', { progress: 100, updatedAt: now - 25 * HOURS }), + ] + const columns = projectColumns(list, now) + // Join, not deepEqual: the module builds its arrays in the test's own vm realm. + const ids = column => columns[column].map(card => card.id).join('|') + assert.equal(ids('inFlight'), 'task-open|task-claimed|task-working') + assert.equal(ids('waiting'), 'task-waiting|task-result') + assert.equal(ids('delivered'), 'task-shipped') + const seen = [...columns.inFlight, ...columns.waiting, ...columns.delivered].map(card => card.id) + assert.equal(new Set(seen).size, seen.length, 'no task is counted twice') + assert.equal(seen.length, 6, 'the only card outside the three columns is the one older than 24h') +}) + +test('#1701: delivered today reads status and a 24h window on updatedAt', () => { + const now = 1_000 * HOURS + const columns = projectColumns([ + task('task-fresh', 'accepted', { progress: 100, updatedAt: now - 1 * HOURS }), + task('task-stale', 'accepted', { progress: 100, updatedAt: now - 30 * HOURS }), + task('task-cancelled', 'cancelled', { updatedAt: now - 1 * HOURS }), + task('task-failed', 'failed', { updatedAt: now - 1 * HOURS }), + task('task-working', 'working', { updatedAt: now - 1 * HOURS }), + ], now) + assert.equal(columns.delivered.map(card => card.id).join('|'), 'task-fresh') + assert.equal(columns.inFlight.some(card => card.id === 'task-working'), true, 'a fresh working card is in flight, not delivered') + assert.equal(columns.waiting.some(card => card.id === 'task-failed'), false, 'a failed card is not waiting on the owner') + const everywhere = [...columns.inFlight, ...columns.waiting, ...columns.delivered].map(card => card.id) + for (const gone of ['task-stale', 'task-cancelled', 'task-failed']) assert.equal(everywhere.includes(gone), false, gone) +}) + +test('#1701: projectScope is the one place that turns a project into its members', () => { + const declarations = source.match(/export function projectScope\s*\(/g) ?? [] + assert.equal(declarations.length, 1, 'exactly one declaration of projectScope') + const start = source.indexOf('export function useBoardTasks') + assert.notEqual(start, -1, 'useBoardTasks is a top-level declaration') + const rest = source.slice(start + 'export function useBoardTasks'.length) + const next = Math.min(...['\nexport function ', '\nfunction ', '\nexport const '].map(marker => { + const at = rest.indexOf(marker) + return at === -1 ? Number.POSITIVE_INFINITY : at + })) + const end = start + 'export function useBoardTasks'.length + next + const declaration = source.indexOf('export function projectScope(') + 'export function '.length + const calls = [...source.matchAll(/projectScope\(/g)].map(match => match.index) + assert.ok(calls.length >= 2, 'the declaration and at least one call exist') + for (const at of calls) { + if (at === declaration) continue + assert.ok(at > start && at < end, `projectScope() is called inside useBoardTasks, not at offset ${at}`) + } + assert.equal((source.match(/TEMPORARY/g) ?? []).length, 0, 'the temporary boundary marker is gone') +}) + +test('#1701: a Bot bound to another project never shows on this board', () => { + const roster = rosterWithProjects() + const own = [ + task('task-rocket', 'working', { ownerBotId: 'a' }), + task('task-garden', 'working', { ownerBotId: 'b' }), + task('task-loose', 'working', { ownerBotId: 'c' }), + task('task-room', 'working', { ownerBotId: 'c', origin: { roomId: 'garden' } }), + ] + const scope = projectScope('project-rocket', roster) + assert.equal([...scope.botIds].sort().join('|'), 'a|s') + assert.equal([...scope.roomIds].join('|'), 'war') + + const rocket = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' }, own }) + assert.equal(cardsOf(rocket).map(node => node.props['data-task']).join('|'), 'task-rocket') + + const garden = dialog(roster, { scope: { kind: 'project', projectId: 'project-garden' }, own }) + assert.equal(cardsOf(garden).map(node => node.props['data-task']).join('|'), 'task-garden|task-room') +}) + +test('#1701: the rail lists one row per active project and keeps the Bot rows as they were', () => { + const roster = rosterWithProjects() + const before = boardRail(roster) + const tree = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' } }) + assert.equal(projectRows(tree).map(node => node.props['data-id']).join('|'), 'project-rocket|project-garden', 'archived projects and empty ones are not rail rows') + assert.match(textOf(tree), /Rocket/) + assert.deepEqual(boardRail(roster), before, 'boardRail itself is untouched') + const bots = all(tree).filter(node => node.props?.['data-kind'] === 'bot').map(node => node.props['data-id']) + assert.equal(bots.join('|'), before.bots.map(row => row.id).join('|'), 'every Bot row is still there') + assert.equal(all(tree).filter(node => node.props?.['data-kind'] === 'group').map(node => node.props['data-id']).join('|'), before.groups.map(row => row.id).join('|')) +}) + +test('#1701: with no bound Project the rail shows no Project row and the per-Bot board still opens', () => { + const roster = unboundRoster() + const tree = dialog(roster, { scope: { kind: 'bot', botId: 'a' }, own: [task('task-open', 'open')], projectList: [] }) + assert.equal(projectRows(tree).length, 0, 'no Project row without a binding') + assert.equal(byClass(tree, 'bt-board-rail').length, 1) + assert.equal(cardsOf(tree).map(node => node.props['data-task']).join('|'), 'task-open', 'the per-Bot board still renders its cards') + assert.equal(byClass(tree, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + + // A project whose members were all unbound is not offered either, and nothing throws. + const empty = dialog(unboundRoster(), { scope: { kind: 'bot', botId: 'a' }, projectList: [{ id: 'project-rocket', name: 'Rocket', status: 'active' }] }) + assert.equal(projectRows(empty).length, 0, 'a project with no member is not a rail row') +}) + +test('#1701: the project board is read-only', () => { + const moved = [] + const roster = rosterWithProjects() + const own = [task('task-open', 'open', { ownerBotId: 'a' }), task('task-working', 'working', { ownerBotId: 'a' })] + const tree = dialog(roster, { + scope: { kind: 'project', projectId: 'project-rocket' }, + own, + actions: { taskUpdate: async payload => { moved.push(payload); return payload } }, + }) + const cards = cardsOf(tree) + assert.equal(cards.length, 2) + for (const card of cards) { + assert.ok(card.props['data-column'], 'the column is still drawn') + assert.notEqual(card.props.draggable, true, 'a project card is not draggable') + assert.equal(all(card).some(node => node.type === 'button' && ['Start', 'Complete'].includes(textOf(node))), false, 'no move buttons on a project card') + } + const columns = byClass(tree, 'bt-board-column') + assert.equal(columns.length, 3) + for (const column of columns) { + assert.equal(typeof column.props.onDrop, 'undefined', 'no drop target on the project board') + assert.equal(typeof column.props.onDragOver, 'undefined', 'no drag-over on the project board') + } +}) + +test('#1701: the project board reads the same task call and filters it itself', () => { + const calls = [] + const roster = rosterWithProjects() + const actions = { tasks: (...args) => { calls.push(args); return Promise.resolve([]) }, projects: () => Promise.resolve({ projects }) } + const { useBoardTasks } = module.exports + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'project', projectId: 'project-rocket' }, roster) + assert.equal(effects.length, 1, 'the board loads through one effect') + effects.pop()() + return new Promise(resolve => setImmediate(resolve)).then(() => { + assert.equal(calls.filter(args => args[0]?.botId).length, 0, 'a project board never asks Bot by Bot') + assert.equal(calls.length <= 2, true, 'at most the active read and one archive read') + assert.equal(Object.keys(calls[0]?.[0] ?? {}).length, 0, 'the active read stays the unfiltered list') + }) +}) + +// Two behaviour reds, no new export needed: on the base the board has no project scope at all, so a +// project render falls into the default branch and draws every Bot's card, and the rail has no +// Project row. Both fail for what the board does, not because a function is missing. +test('#1701: a Project board renders no card of a Bot or a group from another Project', () => { + const roster = rosterWithProjects() + const own = [ + task('task-rocket', 'working', { ownerBotId: 'a' }), + task('task-garden', 'working', { ownerBotId: 'b' }), + task('task-room', 'working', { ownerBotId: 'c', origin: { roomId: 'garden' } }), + ] + const tree = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' }, own }) + const shown = cardsOf(tree).map(node => node.props['data-task']) + assert.equal(shown.includes('task-garden'), false, `the other Project's Bot card is on this board: ${shown.join('|')}`) + assert.equal(shown.includes('task-room'), false, `the other Project's group card is on this board: ${shown.join('|')}`) + assert.equal(shown.join('|'), 'task-rocket') +}) + +test('#1701: the rail carries the active Project as one project row', () => { + const roster = rosterWithProjects() + const tree = dialog(roster, { + scope: { kind: 'bot', botId: 'a' }, + projectList: [{ id: 'project-rocket', name: 'Rocket', status: 'active' }], + }) + const rows = projectRows(tree) + assert.equal(rows.length, 1, `expected one project row, saw ${rows.length}`) + assert.equal(rows[0]?.props['data-id'], 'project-rocket') +}) From 85b05d1e9849a03af774a829c956f88597a6f1e1 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-2 Date: Thu, 29 Oct 2026 15:29:23 +0000 Subject: [PATCH 2/3] feat(board): a read-only Project board over the task records (#1701) Slice 1 of #1701: the rail leads with one row per active Project and that scope is a read-only board. projectScope is the one place that turns a project id into its Bots and groups (the roster already carries projectId, so the host is untouched), and projectColumns derives In flight / Waiting on you / Delivered today from the record's own status. No drop target, no move buttons, no add form; a Project with no bound member is not a row. --- changelog.d/1701-project-board.added.md | 1 + core/client.js | 120 ++++++++++++++++--- core/src/client/i18n.js | 5 + core/src/client/index.js | 2 + core/src/client/kanban.js | 139 +++++++++++++++++++--- docs/subsystems/core-client.md | 2 +- patches/ds-bot/files/src/client/kanban.js | 139 +++++++++++++++++++--- 7 files changed, 356 insertions(+), 52 deletions(-) create mode 100644 changelog.d/1701-project-board.added.md diff --git a/changelog.d/1701-project-board.added.md b/changelog.d/1701-project-board.added.md new file mode 100644 index 000000000..04a2a08b6 --- /dev/null +++ b/changelog.d/1701-project-board.added.md @@ -0,0 +1 @@ +A read-only **Project** view on the task board: one row per active Project in the rail, over the Project record and its Bot/group bindings, with **In flight**, **Waiting on you** and **Delivered today** columns drawn from the same task records the per-Bot board already reads ([#1701](https://github.com/ThinkFlowLab/FlyBot/issues/1701)). diff --git a/core/client.js b/core/client.js index 38e5217d1..26e2c9054 100644 --- a/core/client.js +++ b/core/client.js @@ -751,6 +751,11 @@ var ZH = { "Inbox, {count} need you": "收件箱,{count} 项需要你", "Close inbox": "关闭收件箱", "Your Bots": "你的 Bot", + // Project board (#1701) + Project: "项目", + "In flight": "进行中", + "Waiting on you": "等你处理", + "Delivered today": "今日交付", "Needs you": "需要你", "In progress": "进行中", "Nothing needs you right now.": "现在没有需要你处理的事。", @@ -8556,6 +8561,32 @@ function boardColumns(tasks) { for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); return columns; } +function projectScope(projectId, roster) { + const botIds = /* @__PURE__ */ new Set(); + const roomIds = /* @__PURE__ */ new Set(); + if (!projectId) return { botIds, roomIds }; + for (const bot of roster?.bots ?? []) if (bot?.projectId === projectId) botIds.add(bot.id); + for (const room2 of roster?.rooms ?? []) if (room2?.projectId === projectId) roomIds.add(room2.id); + return { botIds, roomIds }; +} +function inProject(task, scope) { + if (!scope) return false; + if (task?.ownerBotId && scope.botIds.has(task.ownerBotId)) return true; + const roomId = task?.origin?.roomId; + return Boolean(roomId && scope.roomIds.has(roomId)); +} +var DELIVERED_MS = 24 * 60 * 60 * 1e3; +function projectColumns(tasks, now = Date.now()) { + const columns = { inFlight: [], waiting: [], delivered: [] }; + for (const task of tasks ?? []) { + const status = task?.status; + if (status === "open" || status === "claimed" || status === "working") columns.inFlight.push(task); + else if (status === "result-ready" || status === "blocked" && task.waitingFor === "input") columns.waiting.push(task); + else if (status === "accepted" && typeof task.updatedAt === "number" && now - task.updatedAt <= DELIVERED_MS) columns.delivered.push(task); + } + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + return columns; +} function columnTree(tasks) { const ids = new Set((tasks ?? []).map((task) => task.id)); const childrenOf = /* @__PURE__ */ new Map(); @@ -8669,11 +8700,12 @@ function lanesOf(tasks, roster) { } var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; var without = (list, id) => list.filter((item) => item.id !== id); -function useBoardTasks(actions, scope) { +function useBoardTasks(actions, scope, roster) { const [own, setOwn] = (0, import_react15.useState)([]); const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); const [archive, setArchive] = (0, import_react15.useState)([]); - const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; + const [projects, setProjects] = (0, import_react15.useState)([]); + const key = scope?.kind === "group" ? `group:${scope.roomId}` : scope?.kind === "project" ? `project:${scope.projectId}` : "own"; (0, import_react15.useEffect)(() => { let stopped = false; const load = () => { @@ -8689,6 +8721,11 @@ function useBoardTasks(actions, scope) { if (stopped || !Array.isArray(list)) return; setArchive(list.filter((task) => columnOf(task) === "done")); }).catch(() => void 0); + void Promise.resolve(actions?.projects?.()).then((value) => { + if (stopped) return; + const list = Array.isArray(value?.projects) ? value.projects : Array.isArray(value) ? value : []; + setProjects(list); + }).catch(() => void 0); }; load(); const timer = setInterval(load, POLL_MS3); @@ -8697,15 +8734,22 @@ function useBoardTasks(actions, scope) { clearInterval(timer); }; }, [key]); - const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const projectRows = (projects ?? []).flatMap((project) => { + if (!project || project.status !== void 0 && project.status !== "active") return []; + const members = projectScope(project.id, roster); + const memberCount2 = members.botIds.size + members.roomIds.size; + return memberCount2 > 0 ? [{ kind: "project", id: project.id, name: project.name ?? project.id, memberCount: memberCount2 }] : []; + }); + const projectSet = scope?.kind === "project" ? projectScope(scope.projectId, roster) : null; + const tasks = scope?.kind === "group" ? groupTasks : scope?.kind === "project" ? own.filter((task) => inProject(task, projectSet)) : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); const replace = (task) => { const done2 = columnOf(task) === "done"; if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); }; - const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); - return { own, tasks, archive: done, replace }; + const done = scope?.kind === "group" ? archive : scope?.kind === "project" ? archive.filter((task) => inProject(task, projectSet)) : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + return { own, tasks, archive: done, replace, projects: projectRows }; } function RailButton({ row, selected, meta, onSelect }) { return (0, import_react15.createElement)( @@ -8718,7 +8762,7 @@ function RailButton({ row, selected, meta, onSelect }) { "data-selected": selected || void 0, "aria-current": selected ? "true" : void 0, style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, - onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) + onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : row.kind === "project" ? { kind: "project", projectId: row.id } : { kind: "bot", botId: row.id }) }, (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null @@ -8777,8 +8821,33 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) onDragEnd: () => onDrag?.(null) }); } -function cardProps(task, roster, onMove, onDrag, partOf2) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; +function cardProps(task, roster, onMove, onDrag, partOf2, readOnly = false) { + return { + task, + roster, + movable: readOnly ? false : canMoveTask(roster, task), + onMove: readOnly ? void 0 : onMove, + onDrag: readOnly ? void 0 : onDrag, + partOf: partOf2 + }; +} +function ProjectColumn({ column, title, tasks, roster }) { + return (0, import_react15.createElement)( + "section", + { + className: "bt-board-column", + "data-project-column": column, + "aria-label": title + }, + (0, import_react15.createElement)( + "h3", + { className: "bt-board-column-head" }, + (0, import_react15.createElement)("span", null, title), + (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) + ), + tasks.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, + tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, void 0, void 0, "", true) })) + ); } function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { const tree = lanes ? columnTree(tasks) : null; @@ -8829,16 +8898,26 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) ); } function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { - const columns = boardColumns(tasks); + const project = scope?.kind === "project" ? scope.projectId : ""; + const columns = project ? projectColumns(tasks) : boardColumns(tasks); const lanes = scope?.kind === "group"; const selectedBot = scope?.kind === "bot" ? scope.botId : ""; const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + const projectRows = rail.projects ?? []; return (0, import_react15.createElement)( "div", { className: "bt-board-body" }, (0, import_react15.createElement)( "aside", { className: "bt-board-rail", "aria-label": t("Task board") }, + projectRows.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Project")) : null, + projectRows.map((row) => (0, import_react15.createElement)(RailButton, { + key: row.id, + row, + selected: row.id === project, + onSelect, + meta: t("{count} members", { count: row.memberCount }) + })), (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { const count = counts2?.[row.id] ?? { done: 0, total: 0 }; @@ -8870,7 +8949,13 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro (0, import_react15.createElement)( "div", { className: "bt-board-columns" }, - ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { + project ? [["inFlight", t("In flight")], ["waiting", t("Waiting on you")], ["delivered", t("Delivered today")]].map(([column, title]) => (0, import_react15.createElement)(ProjectColumn, { + key: column, + column, + title, + tasks: columns[column], + roster + })) : ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { key: column, column, tasks: columns[column], @@ -8902,18 +8987,20 @@ function KanbanRow({ wide, useUi, actions }) { wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null ); } -function scopeName(roster, scope) { +function scopeName(roster, scope, projects) { if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; + if (scope?.kind === "project") return (projects ?? []).find((row) => row.id === scope.projectId)?.name ?? ""; return ""; } function KanbanDialog({ roster, actions, leaving = false }) { - const rail = boardRail(roster); - const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); + const baseRail = boardRail(roster); + const [scope, setScope] = (0, import_react15.useState)(() => baseRail.bots[0] ? { kind: "bot", botId: baseRail.bots[0].id } : baseRail.groups[0] ? { kind: "group", roomId: baseRail.groups[0].id } : null); const [dragging, setDragging] = (0, import_react15.useState)(null); const [error, setError] = (0, import_react15.useState)(""); const [draft, setDraft] = (0, import_react15.useState)(""); - const { own, tasks, archive, replace } = useBoardTasks(actions, scope); + const { own, tasks, archive, replace, projects: projectRows } = useBoardTasks(actions, scope, roster); + const rail = { ...baseRail, projects: projectRows }; const boardTasks = boardTasksFor(tasks, archive); const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); useEscape(close); @@ -8926,6 +9013,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === "done") bucket.done += 1; } const move = async (id, column) => { + if (scope?.kind === "project") return; const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; setError(""); @@ -8946,7 +9034,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { setError(failure?.message ?? String(failure)); } }; - const name = scopeName(roster, scope); + const name = scopeName(roster, scope, rail.projects); return (0, import_react15.createElement)( "div", { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, @@ -20588,6 +20676,8 @@ function apply(ctx) { setBotConnector: (botId, connector, enabled) => after(call("bot-connectors", { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), tasks: (payload) => call("tasks", payload ?? {}), + // The Project record and its bindings (#1691); the board reads the list to name its rail (#1701). + projects: () => call("projects"), taskUpdate: (payload) => call("task-update", payload), taskCreate: (payload) => call("task-create", payload), schedules: () => call("schedules"), diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index c2443864d..427d3e483 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -695,6 +695,11 @@ const ZH = { 'Inbox, {count} need you': '收件箱,{count} 项需要你', 'Close inbox': '关闭收件箱', 'Your Bots': '你的 Bot', + // Project board (#1701) + Project: '项目', + 'In flight': '进行中', + 'Waiting on you': '等你处理', + 'Delivered today': '今日交付', 'Needs you': '需要你', 'In progress': '进行中', 'Nothing needs you right now.': '现在没有需要你处理的事。', diff --git a/core/src/client/index.js b/core/src/client/index.js index f57018f37..67313b66e 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -620,6 +620,8 @@ export function apply(ctx) { setBotConnector: (botId, connector, enabled) => after(call('bot-connectors', { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), tasks: (payload) => call('tasks', payload ?? {}), + // The Project record and its bindings (#1691); the board reads the list to name its rail (#1701). + projects: () => call('projects'), taskUpdate: (payload) => call('task-update', payload), taskCreate: (payload) => call('task-create', payload), schedules: () => call('schedules'), diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index d3dd00e34..214df3f43 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -1,6 +1,7 @@ // Per-Bot task board (#1058). The records are the task list in src/host/tasks.js. // This is not the Agent inbox's board (#471): Inbox stays a list of what needs the -// owner. Tasks opens one Bot's board, a child of that Bot, or a group an admin can see. +// owner. Tasks opens one Bot's board, a child of that Bot, a group an admin can see, +// or a read-only Project board (#1701) over that same task read. import { createElement as h, useCallback, useEffect, useState } from 'react' import { columnOf, priorityOf, progressOf } from '../shared/task-board.js' import { taskCard } from '../shared/board-card.js' @@ -44,6 +45,43 @@ export function boardColumns(tasks) { return columns } +// Project scope, slice 1 (#1701). The identity is the durable Project record (#1695): a Bot or a +// group names a project on its own record and the roster already carries `projectId`, so the board +// only narrows the one task read — no second store, no new endpoint, no second identity. This is the +// single place that turns a project id into the members it holds. +export function projectScope(projectId, roster) { + const botIds = new Set() + const roomIds = new Set() + if (!projectId) return { botIds, roomIds } + for (const bot of roster?.bots ?? []) if (bot?.projectId === projectId) botIds.add(bot.id) + for (const room of roster?.rooms ?? []) if (room?.projectId === projectId) roomIds.add(room.id) + return { botIds, roomIds } +} + +function inProject(task, scope) { + if (!scope) return false + if (task?.ownerBotId && scope.botIds.has(task.ownerBotId)) return true + const roomId = task?.origin?.roomId + return Boolean(roomId && scope.roomIds.has(roomId)) +} + +// The window "Delivered today" reads. A finished card older than this is history, not today. +export const DELIVERED_MS = 24 * 60 * 60 * 1000 + +// The Project board's three columns are the task lifecycle, not the per-Bot board's drop targets, +// and they are disjoint: one card lands in exactly one of them, or in none (done, older than a day). +export function projectColumns(tasks, now = Date.now()) { + const columns = { inFlight: [], waiting: [], delivered: [] } + for (const task of tasks ?? []) { + const status = task?.status + if (status === 'open' || status === 'claimed' || status === 'working') columns.inFlight.push(task) + else if (status === 'result-ready' || (status === 'blocked' && task.waitingFor === 'input')) columns.waiting.push(task) + else if (status === 'accepted' && typeof task.updatedAt === 'number' && now - task.updatedAt <= DELIVERED_MS) columns.delivered.push(task) + } + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + return columns +} + // Group scope: an overall task (kind group) keeps the subtasks that share its // column nested under it. A subtask whose parent sits in another column stays // loose so the column can name the parent. @@ -163,11 +201,12 @@ function lanesOf(tasks, roster) { const upsert = (list, task) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) const without = (list, id) => list.filter(item => item.id !== id) -export function useBoardTasks(actions, scope) { +export function useBoardTasks(actions, scope, roster) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) const [archive, setArchive] = useState([]) - const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' + const [projects, setProjects] = useState([]) + const key = scope?.kind === 'group' ? `group:${scope.roomId}` : scope?.kind === 'project' ? `project:${scope.projectId}` : 'own' useEffect(() => { let stopped = false const load = () => { @@ -191,12 +230,32 @@ export function useBoardTasks(actions, scope) { if (stopped || !Array.isArray(list)) return setArchive(list.filter(task => columnOf(task) === 'done')) }).catch(() => undefined) + // The Project record itself is the only extra read (#1701); a host without the endpoint keeps + // the last list, so a Bot board never depends on it. + void Promise.resolve(actions?.projects?.()).then((value) => { + if (stopped) return + const list = Array.isArray(value?.projects) ? value.projects : Array.isArray(value) ? value : [] + setProjects(list) + }).catch(() => undefined) } load() const timer = setInterval(load, POLL_MS) return () => { stopped = true; clearInterval(timer) } }, [key]) - const tasks = scope?.kind === 'group' ? groupTasks : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + // The rail rows: an active Project that holds at least one bound Bot or group. A Project with no + // member is not offered, and an unbound install shows no Project row at all (#1701 fallback). + const projectRows = (projects ?? []).flatMap((project) => { + if (!project || (project.status !== undefined && project.status !== 'active')) return [] + const members = projectScope(project.id, roster) + const memberCount = members.botIds.size + members.roomIds.size + return memberCount > 0 ? [{ kind: 'project', id: project.id, name: project.name ?? project.id, memberCount }] : [] + }) + const projectSet = scope?.kind === 'project' ? projectScope(scope.projectId, roster) : null + const tasks = scope?.kind === 'group' + ? groupTasks + : scope?.kind === 'project' + ? own.filter(task => inProject(task, projectSet)) + : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) // A finished card moves off the active list into the archive immediately, before the next poll. const replace = (task) => { const done = columnOf(task) === 'done' @@ -204,8 +263,12 @@ export function useBoardTasks(actions, scope) { else setOwn(list => done ? without(list, task.id) : upsert(list, task)) setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id)) } - const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) - return { own, tasks, archive: done, replace } + const done = scope?.kind === 'group' + ? archive + : scope?.kind === 'project' + ? archive.filter(task => inProject(task, projectSet)) + : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + return { own, tasks, archive: done, replace, projects: projectRows } } function RailButton({ row, selected, meta, onSelect }) { @@ -217,7 +280,11 @@ function RailButton({ row, selected, meta, onSelect }) { 'data-selected': selected || undefined, 'aria-current': selected ? 'true' : undefined, style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : undefined, - onClick: () => onSelect(row.kind === 'group' ? { kind: 'group', roomId: row.id } : { kind: 'bot', botId: row.id }), + onClick: () => onSelect(row.kind === 'group' + ? { kind: 'group', roomId: row.id } + : row.kind === 'project' + ? { kind: 'project', projectId: row.id } + : { kind: 'bot', botId: row.id }), }, h('span', { className: 'bt-board-rail-name' }, row.name), meta ? h('span', { className: 'bt-board-rail-meta' }, meta) : null) @@ -274,8 +341,29 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { }) } -function cardProps(task, roster, onMove, onDrag, partOf) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } +function cardProps(task, roster, onMove, onDrag, partOf, readOnly = false) { + return { + task, + roster, + movable: readOnly ? false : canMoveTask(roster, task), + onMove: readOnly ? undefined : onMove, + onDrag: readOnly ? undefined : onDrag, + partOf, + } +} + +// A Project column (#1701): the same card, no drop target and no move action — slice 1 is a read. +function ProjectColumn({ column, title, tasks, roster }) { + return h('section', { + className: 'bt-board-column', + 'data-project-column': column, + 'aria-label': title, + }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, title), + h('span', { className: 'bt-board-count' }, String(tasks.length))), + tasks.length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, + tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, undefined, undefined, '', true) }))) } function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { @@ -312,12 +400,19 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) } export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { - const columns = boardColumns(tasks) + const project = scope?.kind === 'project' ? scope.projectId : '' + const columns = project ? projectColumns(tasks) : boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' + const projectRows = rail.projects ?? [] return h('div', { className: 'bt-board-body' }, h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, + projectRows.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Project')) : null, + projectRows.map(row => h(RailButton, { + key: row.id, row, selected: row.id === project, onSelect, + meta: t('{count} members', { count: row.memberCount }), + })), 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 } @@ -335,9 +430,13 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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, h('div', { className: 'bt-board-columns' }, - ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, - }))))) + project + ? [['inFlight', t('In flight')], ['waiting', t('Waiting on you')], ['delivered', t('Delivered today')]].map(([column, title]) => h(ProjectColumn, { + key: column, column, title, tasks: columns[column], roster, + })) + : ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, + }))))) } export function KanbanRow({ wide, useUi, actions }) { @@ -351,20 +450,22 @@ export function KanbanRow({ wide, useUi, actions }) { wide ? h('span', { className: 'bt-board-label' }, label) : null) } -function scopeName(roster, scope) { +function scopeName(roster, scope, projects) { if (scope?.kind === 'group') return roster.roomsById?.[scope.roomId]?.name || t('Group') if (scope?.kind === 'bot') return roster.byId?.[scope.botId]?.name || '' + if (scope?.kind === 'project') return (projects ?? []).find(row => row.id === scope.projectId)?.name ?? '' return '' } export function KanbanDialog({ roster, actions, leaving = false }) { // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). - 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 baseRail = boardRail(roster) + const [scope, setScope] = useState(() => (baseRail.bots[0] ? { kind: 'bot', botId: baseRail.bots[0].id } : baseRail.groups[0] ? { kind: 'group', roomId: baseRail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) const [error, setError] = useState('') const [draft, setDraft] = useState('') - const { own, tasks, archive, replace } = useBoardTasks(actions, scope) + const { own, tasks, archive, replace, projects: projectRows } = useBoardTasks(actions, scope, roster) + const rail = { ...baseRail, projects: projectRows } const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) @@ -377,6 +478,8 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === 'done') bucket.done += 1 } const move = async (id, column) => { + // A Project board is read-only in slice 1 (#1701): no column move, no drag. + if (scope?.kind === 'project') return const task = tasks.find(item => item.id === id) ?? own.find(item => item.id === id) if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return setError('') @@ -397,7 +500,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { setError(failure?.message ?? String(failure)) } } - const name = scopeName(roster, scope) + const name = scopeName(roster, scope, rail.projects) return h('div', { className: 'bt-mem-layer bt-board-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onClick: close }), h('div', { className: 'bt-mem bt-board', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'bt-board-heading' }, diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 440ba288e..9d05fa389 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -43,7 +43,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. 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 body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card 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. Since #1701 the rail also leads with one **Project** row per active Project, and that scope is a read-only board: the identity is the durable Project record (#1695) whose id a Bot or a group names on its own record, `projectScope(projectId, roster)` is the one place that turns a project id into its Bots and groups (the roster already carries `projectId`, so no host change and no new endpoint), `useBoardTasks` narrows the same unfiltered task read with it, and `projectColumns` derives **In flight** / **Waiting on you** / **Delivered today** from the record's own status — disjoint, with a card accepted more than `DELIVERED_MS` ago in none of them. That scope has no drop target, no **Start** / **Complete** and no add form; a Project with no bound member is not a rail row and an install with no binding shows no Project row, so a per-Bot board opens exactly as before. Tests: `core/test/kanban.test.js`, `core/test/board-project.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/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index d3dd00e34..214df3f43 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -1,6 +1,7 @@ // Per-Bot task board (#1058). The records are the task list in src/host/tasks.js. // This is not the Agent inbox's board (#471): Inbox stays a list of what needs the -// owner. Tasks opens one Bot's board, a child of that Bot, or a group an admin can see. +// owner. Tasks opens one Bot's board, a child of that Bot, a group an admin can see, +// or a read-only Project board (#1701) over that same task read. import { createElement as h, useCallback, useEffect, useState } from 'react' import { columnOf, priorityOf, progressOf } from '../shared/task-board.js' import { taskCard } from '../shared/board-card.js' @@ -44,6 +45,43 @@ export function boardColumns(tasks) { return columns } +// Project scope, slice 1 (#1701). The identity is the durable Project record (#1695): a Bot or a +// group names a project on its own record and the roster already carries `projectId`, so the board +// only narrows the one task read — no second store, no new endpoint, no second identity. This is the +// single place that turns a project id into the members it holds. +export function projectScope(projectId, roster) { + const botIds = new Set() + const roomIds = new Set() + if (!projectId) return { botIds, roomIds } + for (const bot of roster?.bots ?? []) if (bot?.projectId === projectId) botIds.add(bot.id) + for (const room of roster?.rooms ?? []) if (room?.projectId === projectId) roomIds.add(room.id) + return { botIds, roomIds } +} + +function inProject(task, scope) { + if (!scope) return false + if (task?.ownerBotId && scope.botIds.has(task.ownerBotId)) return true + const roomId = task?.origin?.roomId + return Boolean(roomId && scope.roomIds.has(roomId)) +} + +// The window "Delivered today" reads. A finished card older than this is history, not today. +export const DELIVERED_MS = 24 * 60 * 60 * 1000 + +// The Project board's three columns are the task lifecycle, not the per-Bot board's drop targets, +// and they are disjoint: one card lands in exactly one of them, or in none (done, older than a day). +export function projectColumns(tasks, now = Date.now()) { + const columns = { inFlight: [], waiting: [], delivered: [] } + for (const task of tasks ?? []) { + const status = task?.status + if (status === 'open' || status === 'claimed' || status === 'working') columns.inFlight.push(task) + else if (status === 'result-ready' || (status === 'blocked' && task.waitingFor === 'input')) columns.waiting.push(task) + else if (status === 'accepted' && typeof task.updatedAt === 'number' && now - task.updatedAt <= DELIVERED_MS) columns.delivered.push(task) + } + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + return columns +} + // Group scope: an overall task (kind group) keeps the subtasks that share its // column nested under it. A subtask whose parent sits in another column stays // loose so the column can name the parent. @@ -163,11 +201,12 @@ function lanesOf(tasks, roster) { const upsert = (list, task) => (list.some(item => item.id === task.id) ? list.map(item => item.id === task.id ? task : item) : [...list, task]) const without = (list, id) => list.filter(item => item.id !== id) -export function useBoardTasks(actions, scope) { +export function useBoardTasks(actions, scope, roster) { const [own, setOwn] = useState([]) const [groupTasks, setGroupTasks] = useState([]) const [archive, setArchive] = useState([]) - const key = scope?.kind === 'group' ? `group:${scope.roomId}` : 'own' + const [projects, setProjects] = useState([]) + const key = scope?.kind === 'group' ? `group:${scope.roomId}` : scope?.kind === 'project' ? `project:${scope.projectId}` : 'own' useEffect(() => { let stopped = false const load = () => { @@ -191,12 +230,32 @@ export function useBoardTasks(actions, scope) { if (stopped || !Array.isArray(list)) return setArchive(list.filter(task => columnOf(task) === 'done')) }).catch(() => undefined) + // The Project record itself is the only extra read (#1701); a host without the endpoint keeps + // the last list, so a Bot board never depends on it. + void Promise.resolve(actions?.projects?.()).then((value) => { + if (stopped) return + const list = Array.isArray(value?.projects) ? value.projects : Array.isArray(value) ? value : [] + setProjects(list) + }).catch(() => undefined) } load() const timer = setInterval(load, POLL_MS) return () => { stopped = true; clearInterval(timer) } }, [key]) - const tasks = scope?.kind === 'group' ? groupTasks : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + // The rail rows: an active Project that holds at least one bound Bot or group. A Project with no + // member is not offered, and an unbound install shows no Project row at all (#1701 fallback). + const projectRows = (projects ?? []).flatMap((project) => { + if (!project || (project.status !== undefined && project.status !== 'active')) return [] + const members = projectScope(project.id, roster) + const memberCount = members.botIds.size + members.roomIds.size + return memberCount > 0 ? [{ kind: 'project', id: project.id, name: project.name ?? project.id, memberCount }] : [] + }) + const projectSet = scope?.kind === 'project' ? projectScope(scope.projectId, roster) : null + const tasks = scope?.kind === 'group' + ? groupTasks + : scope?.kind === 'project' + ? own.filter(task => inProject(task, projectSet)) + : own.filter(task => !scope?.botId || task.ownerBotId === scope.botId) // A finished card moves off the active list into the archive immediately, before the next poll. const replace = (task) => { const done = columnOf(task) === 'done' @@ -204,8 +263,12 @@ export function useBoardTasks(actions, scope) { else setOwn(list => done ? without(list, task.id) : upsert(list, task)) setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id)) } - const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) - return { own, tasks, archive: done, replace } + const done = scope?.kind === 'group' + ? archive + : scope?.kind === 'project' + ? archive.filter(task => inProject(task, projectSet)) + : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId) + return { own, tasks, archive: done, replace, projects: projectRows } } function RailButton({ row, selected, meta, onSelect }) { @@ -217,7 +280,11 @@ function RailButton({ row, selected, meta, onSelect }) { 'data-selected': selected || undefined, 'aria-current': selected ? 'true' : undefined, style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : undefined, - onClick: () => onSelect(row.kind === 'group' ? { kind: 'group', roomId: row.id } : { kind: 'bot', botId: row.id }), + onClick: () => onSelect(row.kind === 'group' + ? { kind: 'group', roomId: row.id } + : row.kind === 'project' + ? { kind: 'project', projectId: row.id } + : { kind: 'bot', botId: row.id }), }, h('span', { className: 'bt-board-rail-name' }, row.name), meta ? h('span', { className: 'bt-board-rail-meta' }, meta) : null) @@ -274,8 +341,29 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { }) } -function cardProps(task, roster, onMove, onDrag, partOf) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } +function cardProps(task, roster, onMove, onDrag, partOf, readOnly = false) { + return { + task, + roster, + movable: readOnly ? false : canMoveTask(roster, task), + onMove: readOnly ? undefined : onMove, + onDrag: readOnly ? undefined : onDrag, + partOf, + } +} + +// A Project column (#1701): the same card, no drop target and no move action — slice 1 is a read. +function ProjectColumn({ column, title, tasks, roster }) { + return h('section', { + className: 'bt-board-column', + 'data-project-column': column, + 'aria-label': title, + }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, title), + h('span', { className: 'bt-board-count' }, String(tasks.length))), + tasks.length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, + tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, undefined, undefined, '', true) }))) } function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { @@ -312,12 +400,19 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) } export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { - const columns = boardColumns(tasks) + const project = scope?.kind === 'project' ? scope.projectId : '' + const columns = project ? projectColumns(tasks) : boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' + const projectRows = rail.projects ?? [] return h('div', { className: 'bt-board-body' }, h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, + projectRows.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Project')) : null, + projectRows.map(row => h(RailButton, { + key: row.id, row, selected: row.id === project, onSelect, + meta: t('{count} members', { count: row.memberCount }), + })), 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 } @@ -335,9 +430,13 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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, h('div', { className: 'bt-board-columns' }, - ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, - }))))) + project + ? [['inFlight', t('In flight')], ['waiting', t('Waiting on you')], ['delivered', t('Delivered today')]].map(([column, title]) => h(ProjectColumn, { + key: column, column, title, tasks: columns[column], roster, + })) + : ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, + }))))) } export function KanbanRow({ wide, useUi, actions }) { @@ -351,20 +450,22 @@ export function KanbanRow({ wide, useUi, actions }) { wide ? h('span', { className: 'bt-board-label' }, label) : null) } -function scopeName(roster, scope) { +function scopeName(roster, scope, projects) { if (scope?.kind === 'group') return roster.roomsById?.[scope.roomId]?.name || t('Group') if (scope?.kind === 'bot') return roster.byId?.[scope.botId]?.name || '' + if (scope?.kind === 'project') return (projects ?? []).find(row => row.id === scope.projectId)?.name ?? '' return '' } export function KanbanDialog({ roster, actions, leaving = false }) { // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). - 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 baseRail = boardRail(roster) + const [scope, setScope] = useState(() => (baseRail.bots[0] ? { kind: 'bot', botId: baseRail.bots[0].id } : baseRail.groups[0] ? { kind: 'group', roomId: baseRail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) const [error, setError] = useState('') const [draft, setDraft] = useState('') - const { own, tasks, archive, replace } = useBoardTasks(actions, scope) + const { own, tasks, archive, replace, projects: projectRows } = useBoardTasks(actions, scope, roster) + const rail = { ...baseRail, projects: projectRows } const boardTasks = boardTasksFor(tasks, archive) const close = useCallback(() => actions.closeBoard(), []) useEscape(close) @@ -377,6 +478,8 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === 'done') bucket.done += 1 } const move = async (id, column) => { + // A Project board is read-only in slice 1 (#1701): no column move, no drag. + if (scope?.kind === 'project') return const task = tasks.find(item => item.id === id) ?? own.find(item => item.id === id) if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return setError('') @@ -397,7 +500,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { setError(failure?.message ?? String(failure)) } } - const name = scopeName(roster, scope) + const name = scopeName(roster, scope, rail.projects) return h('div', { className: 'bt-mem-layer bt-board-layer', 'data-leaving': leaving || undefined }, h('div', { className: 'bt-scrim', onClick: close }), h('div', { className: 'bt-mem bt-board', role: 'dialog', 'aria-modal': true, 'aria-labelledby': 'bt-board-heading' }, From 61d30503ee03ad7ea1182b19da6b98fd771f8e99 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-2 Date: Thu, 29 Oct 2026 15:30:18 +0000 Subject: [PATCH 3/3] chore(patches): regenerate the ds-bot mirrors for the board change (#1701) Generated from core/ after the new test file; the four pre-existing generator drifts (0002/0005 hunk context) are left as main has them. --- patches/ds-bot/0001-core-fixes.patch | 7 +- patches/ds-bot/0003-flybot-api-client.patch | 4 +- patches/ds-bot/0008-client-bundle.patch | 2434 ++++++----------- .../ds-bot/files/test/board-project.test.js | 276 ++ 4 files changed, 1144 insertions(+), 1577 deletions(-) create mode 100644 patches/ds-bot/files/test/board-project.test.js diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index bea3c42ce..ba0f169e4 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1078,7 +1078,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'You have {version}.': '当前版本 {version}。', 'The update did not finish: {reason}': '更新没有完成:{reason}', "What's new": '更新说明', -@@ -360,9 +360,75 @@ const ZH = { +@@ -360,9 +360,80 @@ const ZH = { 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -1091,6 +1091,11 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Inbox, {count} need you': '收件箱,{count} 项需要你', + 'Close inbox': '关闭收件箱', + 'Your Bots': '你的 Bot', ++ // Project board (#1701) ++ Project: '项目', ++ 'In flight': '进行中', ++ 'Waiting on you': '等你处理', ++ 'Delivered today': '今日交付', + 'Needs you': '需要你', + 'In progress': '进行中', + 'Nothing needs you right now.': '现在没有需要你处理的事。', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index cead3e9b7..7c1ccb3d0 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1617,7 +1617,7 @@ diff --git a/src/client/index.js b/src/client/index.js stopMemberWork: (roomId, botId) => after(call('stop-member-work', { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: payload => after(call('secret-set', payload)), -@@ -539,15 +539,33 @@ export function apply(ctx) { +@@ -539,15 +539,35 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), secretDelete: name => after(call('secret-delete', { name })), @@ -1641,6 +1641,8 @@ diff --git a/src/client/index.js b/src/client/index.js + setBotConnector: (botId, connector, enabled) => after(call('bot-connectors', { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), + tasks: (payload) => call('tasks', payload ?? {}), ++ // The Project record and its bindings (#1691); the board reads the list to name its rail (#1701). ++ projects: () => call('projects'), + taskUpdate: (payload) => call('task-update', payload), + taskCreate: (payload) => call('task-create', payload), schedules: () => call('schedules'), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ded73eb24..c7896772a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -499,7 +499,7 @@ diff --git a/client.js b/client.js "You have {version}.": "当前版本 {version}。", "The update did not finish: {reason}": "更新没有完成:{reason}", "What's new": "更新说明", -@@ -420,8 +420,74 @@ var ZH = { +@@ -420,8 +420,79 @@ var ZH = { "DSH update": "DSH 更新", "DSH {version}": "DSH {version}", "Restart DSH to start using DSH {version}.": "重启 DSH 后开始使用 DSH {version}。", @@ -511,6 +511,11 @@ diff --git a/client.js b/client.js + "Inbox, {count} need you": "收件箱,{count} 项需要你", + "Close inbox": "关闭收件箱", + "Your Bots": "你的 Bot", ++ // Project board (#1701) ++ Project: "项目", ++ "In flight": "进行中", ++ "Waiting on you": "等你处理", ++ "Delivered today": "今日交付", + "Needs you": "需要你", + "In progress": "进行中", + "Nothing needs you right now.": "现在没有需要你处理的事。", @@ -1883,7 +1888,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,491 +1624,1490 @@ function usageOf(list, ids) { +@@ -1624,201 +1624,1174 @@ 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 }); } @@ -2145,8 +2150,6 @@ diff --git a/client.js b/client.js - animation.onfinish = () => { - delete node.dataset.morph; - }; -- animation.oncancel = () => { -- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; @@ -2180,8 +2183,7 @@ diff --git a/client.js b/client.js + 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); @@ -2199,9 +2201,7 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); - } --function useLinger(value, ms) { -- const [kept, setKept] = (0, import_react.useState)(value); ++} +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 ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -2281,22 +2281,17 @@ diff --git a/client.js b/client.js +function useSettled(state) { + const prev = (0, import_react.useRef)(state); + const [settled, setSettled] = (0, import_react.useState)(false); - (0, import_react.useEffect)(() => { -- if (value) { -- setKept(value); ++ (0, import_react.useEffect)(() => { + const was = prev.current; + prev.current = state; + if (state !== "idle") { + setSettled(false); - return void 0; - } -- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); ++ return void 0; ++ } + if (was === "idle" || was === "alert") return void 0; + setSettled(true); + const timer = setTimeout(() => setSettled(false), 1800); - return () => clearTimeout(timer); -- }, [value]); -- return [value || kept, !value && Boolean(kept)]; ++ return () => clearTimeout(timer); + }, [state]); + return settled; +} @@ -2389,108 +2384,8 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } - --// 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)} --body[data-ds-dark-theme]{--bt-ink:#f2f2f2;--bt-ink-2:rgba(242,242,242,.6);--bt-ink-3:rgba(242,242,242,.36);--bt-bubble:#262626;--bt-sidebar:#1b1b1b;--bt-main:#141414;--bt-line:rgba(255,255,255,.08);--bt-line-2:rgba(255,255,255,.15);--bt-line-weak:rgba(255,255,255,.1);--bt-hover:rgba(160,160,160,.12);--bt-active:rgba(160,160,160,.2);--bt-card:#1f1f1f;--bt-user:#f2f2f2;--bt-user-ink:#141414;--bt-tag-bg:rgba(255,255,255,.09);--bt-tag-ink:rgba(242,242,242,.62)} --${Object.entries(INKS).map(([name, [light]]) => `body{--bt-ink-${name}:${light}}`).join("")} --${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt-ink-${name}:${dark}}`).join("")} --.bt-mark{display:inline-block;flex:none;position:relative;line-height:0} --.bt-mark svg{width:100%;height:100%;overflow:visible} --.bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} --.bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} --.bt-mark .bt-b{transform-origin:50% 100%} --.bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} --/* Motion levels (Settings → Bots → Animation) set body[data-bt-motion]: quiet moves a -- mark only for its state; normal, the default, adds petting, pokes and eyes that follow -- the pointer; lively adds blinks and fidgets at rest and a question riding on the head. */ --body[data-bt-motion=lively] .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{animation:bt-blink-idle 5s linear infinite;animation-delay:var(--bt-delay,0s)} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle]:is([data-fidget=hop],[data-fidget=sway],[data-fidget=stretch]) :is(.bt-b,.bt-mark-img){animation:var(--bt-fidget) 9s ease-in-out var(--bt-fidget-delay,0s) infinite} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=hop]{--bt-fidget:bt-fidget-hop} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=sway]{--bt-fidget:bt-fidget-sway} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=stretch]{--bt-fidget:bt-fidget-stretch} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=look] .bt-eyes{animation:bt-fidget-look 9s ease-in-out var(--bt-fidget-delay,0s) infinite} --@keyframes bt-fidget-hop{0%,8%,100%{transform:none}3%{transform:translateY(-10%) scale(.97,1.04)}5.5%{transform:translateY(0) scale(1.05,.95)}} --@keyframes bt-fidget-sway{0%,10%,100%{transform:none}2.5%{transform:rotate(-8deg)}5%{transform:rotate(7deg)}7.5%{transform:rotate(-3deg)}} --@keyframes bt-fidget-stretch{0%,9%,100%{transform:none}4%{transform:scale(.94,1.08)}7%{transform:scale(1.03,.97)}} --@keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} --body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} --body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} --@keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} --/* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its -- size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -- closed in a happy arc, and springs back when the pointer leaves. */ --:is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster)::before{content:'';position:absolute;inset:-20%;border-radius:50%} --.bt-mark[data-live] .bt-b,.bt-mark[data-live] .bt-mark-img{transition:rotate .5s cubic-bezier(.34,1.56,.64,1),translate .5s cubic-bezier(.34,1.56,.64,1),scale .3s ease-out} --.bt-mark[data-live] .bt-eye{transition:scale .14s ease-in} --.bt-mark[data-live] .bt-eye-happy{transition:opacity .12s ease-out} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]):not(.bt-mark-poke) :is(.bt-b,.bt-mark-img){rotate:8deg;translate:5% -1%;animation:bt-nuzzle .78s ease-in-out .32s infinite} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{scale:1 0;animation:none} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye-happy{opacity:1;transition-delay:.1s} --@keyframes bt-nuzzle{50%{rotate:14deg;translate:8% -3%;scale:1.03 .97}} --.bt-mark[data-gaze] .bt-b{translate:calc(var(--bt-gx,0) * 5%) calc(var(--bt-gy,0) * 4%);transition:translate .18s ease-out} --.bt-mark[data-gaze] .bt-gaze{transform:translate(calc(var(--bt-gx,0) * 12%),calc(var(--bt-gy,0) * 11%));transition:transform .18s ease-out} --body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-b{translate:none} --body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} --.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} --.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} --.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} --.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} --.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} --.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} --/* A question for you hops twice when it arrives and the badge keeps it in view after; -- a lively mark keeps hopping. */ --.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} --.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} --.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} --@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} --.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} --.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} --.bt-mark[data-poke]{cursor:pointer} --body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} --body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} --@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} --@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} --@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} --@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} --@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} --@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} --@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} --@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} --@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} --@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} --@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} --@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} --@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} --/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ --@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} --@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} --@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} --.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} --.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} --.bt-badge-admin svg{width:7px;height:7px} --.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} --.bt-badge-main svg{width:100%;height:100%;overflow:visible} --.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} --.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} --.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} --.bt-cluster>*{position:absolute} --.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} --.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} --.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} ++} ++ +// src/client/room-invite-preview.js +var INVITE_PREVIEW_NAME_MAX = 60; +function stripInviteMarkers(text) { @@ -2571,42 +2466,7 @@ diff --git a/client.js b/client.js + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} - --.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} --.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} --.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} --.bt-round:hover{background:var(--bt-hover)} --.bt-round:active{transform:scale(.94)} --.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} --.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} --.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} --.bt-tile:hover{background:var(--bt-hover)} --.bt-tile[aria-current=page]{background:var(--bt-active)} --.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} --.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} --.bt-row:hover{background:var(--bt-hover)} --.bt-row[aria-current=page]{background:var(--bt-active)} --.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} --.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} --.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} --.bt-unread-wait{background:var(--bt-star)} --.bt-rail{align-items:center;padding:0 0 8px} --.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} --.bt-side-foot{display:flex;padding-top:8px} --.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} --.bt-connect:hover{background:var(--bt-hover)} --.bt-connect svg{flex:none;width:15px;height:15px} --.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0} --.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center} --.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} --@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); @@ -2632,56 +2492,13 @@ diff --git a/client.js b/client.js + claimListeners.delete(fn); + }; +} - --.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} --.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} --.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} --.bt-pill:active{transform:scale(.965)} --.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} --.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} --.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} --.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} --.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)} --.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms} --.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)} --.bt-pill-mem-ico svg{width:16px;height:16px} --.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both} --@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}} --.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} --@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}} --.bt-swap{color:var(--bt-ink-3);font-size:12px} --.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease} --.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none} --.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)} --.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)} --@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}} ++ +// src/shared/approval-waiting.js +var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); +function isApprovalWaiting(status) { + return WAITING_STATUSES.includes(status); +} - --.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} --.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} --.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} --.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} --.bt-bubble [class*="_markdown"]>*+*{margin-top:10px} --.bt-bubble [class*="_markdown"]{color:inherit!important;font-size:inherit!important;line-height:inherit!important} --.bt-bubble p{margin:0} --.bt-bubble p + p{margin-top:6px} --.bt-bubble ul,.bt-bubble ol{margin:4px 0;padding-left:20px} --.bt-bubble a[href^="${MENTION_ORIGIN}"]{text-decoration:none;font-weight:400;cursor:pointer;white-space:nowrap} --.bt-bubble a[href^="${MENTION_ORIGIN}"]::before{content:"";display:inline-block;width:16px;height:16px;margin-right:3px;vertical-align:-3px;background:currentColor;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat} --.bt-group-msg{display:flex;flex-direction:column;gap:4px;margin:16px 0 0} --.bt-group-msg:first-child{margin-top:0} --.bt-lead{flex:none;display:flex;margin-bottom:1px} --button.bt-lead{border:0;background:none;padding:0;cursor:pointer} --/* Rows without the avatar start where the bubble beside it starts: 22px avatar + 8px gap. */ --.bt-astack[data-lead]>.bt-msg:not(:last-child)>.bt-msg-line,.bt-astack[data-lead] .bt-reacts{margin-left:30px} --.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px} --.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)} ++ +// src/shared/held-retry.js +var TERMINAL = [ + // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError). @@ -2701,79 +2518,7 @@ diff --git a/client.js b/client.js + const said = `${record.error ?? ""} ${record.code ?? ""}`; + return !TERMINAL.some((reason) => reason.test(said)); +}; - --.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)} --@keyframes bt-q-in{from{transform:translateY(10px) scale(.97)}} --.bt-q-settled{animation:none} --.bt-q-head{display:flex;align-items:flex-start;gap:8px;min-width:0} --.bt-q-text{flex:1 1 0;min-width:0;display:flex;flex-direction:column} --.bt-q-title{margin:0;font-size:15px;line-height:22px;font-weight:600;overflow-wrap:anywhere} --.bt-q-detail{font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-q-x{flex:none;width:20px;height:20px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-q-x svg{width:14px;height:14px} --.bt-q-x:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-q-x:focus-visible,.bt-q-opt:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --.bt-q-list{display:flex;flex-direction:column;gap:1px;min-width:0;box-sizing:border-box;margin:0 -8px} --.bt-q-opt{position:relative;display:flex;align-items:flex-start;gap:16px;width:100%;min-width:0;box-sizing:border-box;margin:0;border:0;border-radius:12px;background:transparent;padding:10px 8px;font:inherit;color:var(--bt-ink);text-align:start;cursor:pointer;transition:background-color .12s ease} --.bt-q:not(.bt-q-raised)>.bt-q-head:has(+.bt-q-list){padding-bottom:12px;border-bottom:1px solid var(--bt-q-rule)} --.bt-q:not(.bt-q-raised)>.bt-q-head+.bt-q-list{margin-top:-6px} --.bt-q-opt+.bt-q-opt::before{content:"";position:absolute;left:calc(var(--bt-key,24px) + 24px);right:8px;top:-1px;height:1px;background:var(--bt-q-rule-2);transition:opacity .12s ease;pointer-events:none} --button.bt-q-opt:hover::before,button.bt-q-opt:hover+.bt-q-opt::before,.bt-q-opt[aria-pressed=true]::before,.bt-q-opt[aria-pressed=true]+.bt-q-opt::before{opacity:0} --.bt-q-raised .bt-q-opt::before{display:none} --button.bt-q-opt:hover{background:var(--bt-hover)} --button.bt-q-opt:active{background:var(--bt-active)} --.bt-q-opt[aria-pressed=true]{background:var(--bt-active)} --.bt-q:not(.bt-q-raised) :is(button.bt-q-opt:hover,.bt-q-opt[aria-pressed=true]) .bt-key{background:var(--bt-card);color:var(--bt-ink)} --.bt-key{flex:none;box-sizing:border-box;width:var(--bt-key,24px);height:var(--bt-key,24px);border-radius:8px;background:var(--bt-hover);color:var(--bt-ink-2);font-size:12px;line-height:var(--bt-key,24px);font-weight:500;text-align:center;font-variant-numeric:tabular-nums;transition:background-color .12s ease,color .12s ease} --.bt-q-body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;padding-top:2px} --/* With a description the key spans the two text lines less 2px above and below. */ --.bt-q-opt:has(.bt-q-desc){--bt-key:34px} --.bt-q-opt:has(.bt-q-desc) .bt-key{margin-top:2px;border-radius:10px;font-size:13px} --.bt-q-opt:has(.bt-q-desc) .bt-q-body{padding-top:0} --.bt-q-label{font-size:14px;line-height:20px;font-weight:500;white-space:normal;overflow-wrap:anywhere} --.bt-q-desc{font-size:12px;line-height:17px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-q-check{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2);animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-q-settled .bt-q-check{animation:none} --.bt-q-check svg{width:16px;height:16px} --.bt-q-settled .bt-key{opacity:.4} --.bt-q-settled .bt-q-label{color:color-mix(in srgb,var(--bt-ink) 45%,transparent)} --.bt-q-dismissed .bt-q-title{color:var(--bt-ink-2)} --.bt-q.bt-q-settled{gap:6px;padding:12px 16px} --.bt-q-settled .bt-q-title{font-size:14px;line-height:20px;font-weight:500} --.bt-q.bt-q-settled>.bt-q-head:has(+.bt-q-list){padding-bottom:0;border-bottom:0} --.bt-q.bt-q-settled>.bt-q-head+.bt-q-list{margin-top:0} --.bt-q-settled .bt-q-opt{padding:4px 8px} --.bt-q-badge{flex:none;height:20px;padding:0 7px;border-radius:999px;border:1px dotted var(--bt-line-2);color:var(--bt-ink-2);font-size:12px;line-height:18px;box-sizing:border-box} --.bt-q-custom{display:flex;align-items:flex-start;gap:8px;width:100%;min-width:0} --.bt-q-field{flex:1 1 0;min-width:0;display:flex;box-sizing:border-box;min-height:32px;padding:5px 11px;border-radius:8px;border:.5px solid var(--bt-line-2);background:none;cursor:text;transition:border-color .12s ease} --.bt-q-field:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)} --.bt-q-input{flex:1;width:100%;min-width:0;max-height:120px;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;resize:none;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere} --.bt-q-input:not(:placeholder-shown){field-sizing:content} --.bt-q-input::placeholder{color:var(--bt-ink-3)} --.bt-q-submit,.bt-q-btn{flex:none;height:36px;padding:0 14px;border:0;border-radius:999px;font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:opacity .12s ease,background-color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-q-submit{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards,bt-veil-in .16s ease-out backwards} --@keyframes bt-q-submit-in{from{transform:translateX(8px) scale(.9)}} --.bt-q-submit:hover:not(:disabled){opacity:.86} --.bt-q-submit:disabled{opacity:.4;cursor:default} --.bt-q-btn{background:var(--bt-hover);color:var(--bt-ink)} --.bt-q-btn:hover{background:var(--bt-active)} --.bt-q-foot{display:flex;justify-content:flex-end;gap:8px} --.bt-q-raised{width:calc(var(--dsh-chat-content-width,748px) - 32px);max-width:calc(100% - 32px);gap:0;padding:0;box-shadow:0 2px 8px rgba(20,20,20,.035),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 5%,transparent)} --.bt-q-raised .bt-q-head{padding:14px 16px 4px 20px} --.bt-q-raised .bt-q-head:last-child{padding-bottom:16px} --.bt-q-raised .bt-q-list{gap:1px;margin:0;padding:8px} --.bt-q-raised .bt-q-opt{gap:4px;padding:7px;border-radius:16px;color:var(--bt-ink-2)} --.bt-q-raised button.bt-q-opt:hover{color:var(--bt-ink)} --.bt-q-raised .bt-q-opt[aria-pressed=true]{background:transparent;color:var(--bt-ink)} --.bt-q-raised .bt-q-opt[aria-pressed=true]:hover{background:var(--bt-hover)} --.bt-q-raised .bt-q-opt[aria-pressed=true] .bt-key,.bt-q-ownrow:focus-within .bt-key,.bt-q-ownrow[data-filled] .bt-key{background:var(--bt-user);color:var(--bt-user-ink)} --.bt-q-raised .bt-q-body{padding:2px 6px 0} --.bt-q-raised .bt-q-desc{color:color-mix(in srgb,var(--bt-ink) 61%,transparent)} --.bt-q-ownrow{cursor:text} --.bt-q-ownrow .bt-q-input{color:var(--bt-ink)} --.bt-q-raised .bt-q-foot{padding:0 8px 8px} --@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} ++ +// src/client/flybot-slots.js +var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); @@ -3067,32 +2812,7 @@ diff --git a/client.js b/client.js +.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } +.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } +.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } - --.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} --.bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} --.bt-to-label{color:var(--bt-ink-2)} --.bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} --.bt-token{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 6px 0 4px;border-radius:999px;background:var(--bt-hover);font-size:13px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-token button{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;padding:0 2px} --.bt-admin-star{flex:none;display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;corner-shape:round;background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar));vertical-align:-1px} --.bt-to-star{display:inline-flex;margin-right:-2px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-to .bt-to-create{flex:none;height:28px;padding:0 12px;font-size:13px;animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --.bt-drop{position:absolute;top:48px;left:32px;width:min(550px,calc(100% - 64px));display:flex;flex-direction:column;padding:6px;max-height:min(420px,60vh);overflow-y:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 6px 20px -4px rgba(0,0,0,.12);box-sizing:border-box;z-index:1;transform-origin:24px 0;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} --.bt-option{display:flex;align-items:center;gap:10px;border:0;background:none;border-radius:8px;padding:7px 8px;min-height:36px;box-sizing:border-box;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;text-align:left;width:100%} --.bt-option[aria-selected=true]{background:var(--bt-active)} --.bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} --.bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} --.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:none;animation:bt-veil-in .16s ease-out} --.bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} --.bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} --.bt-nc-spacer{flex:1} --.bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} --.bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} --.bt-nc-plus{width:34px;height:34px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2);background:var(--bt-hover);flex:none} --.bt-nc-card textarea{flex:1;resize:none;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;padding:7px 4px;min-height:34px;max-height:140px;box-sizing:border-box} --.bt-nc-send{width:34px;height:34px;border-radius:50%;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;flex:none} --.bt-nc-send:disabled{opacity:.3;cursor:default} --.bt-create{flex:1;display:flex;flex-direction:column;min-width:0} ++ +.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } +/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw + the separator: #818's "no border" held for the component but not for the box around it. Keyed on @@ -3510,52 +3230,31 @@ diff --git a/client.js b/client.js + animation.onfinish = () => { + delete node.dataset.morph; + }; -+ animation.oncancel = () => { -+ if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; -+ }; -+ }); -+} -+function useLinger(value, ms) { + animation.oncancel = () => { + if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; + }; + }); + } + function useLinger(value, ms) { +- const [kept, setKept] = (0, import_react.useState)(value); +- (0, import_react.useEffect)(() => { + const [kept, setKept] = (0, import_react4.useState)(value); + (0, import_react4.useEffect)(() => { -+ if (value) { -+ setKept(value); -+ return void 0; -+ } -+ const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); -+ return () => clearTimeout(timer); -+ }, [value]); -+ return [value || kept, !value && Boolean(kept)]; -+} -+ -+// 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)} -+body[data-ds-dark-theme]{--bt-ink:#f2f2f2;--bt-ink-2:rgba(242,242,242,.6);--bt-ink-3:rgba(242,242,242,.36);--bt-bubble:#262626;--bt-sidebar:#1b1b1b;--bt-main:#141414;--bt-line:rgba(255,255,255,.08);--bt-line-2:rgba(255,255,255,.15);--bt-line-weak:rgba(255,255,255,.1);--bt-hover:rgba(160,160,160,.12);--bt-active:rgba(160,160,160,.2);--bt-card:#1f1f1f;--bt-user:#f2f2f2;--bt-user-ink:#141414;--bt-tag-bg:rgba(255,255,255,.09);--bt-tag-ink:rgba(242,242,242,.62)} -+${Object.entries(INKS).map(([name, [light]]) => `body{--bt-ink-${name}:${light}}`).join("")} -+${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt-ink-${name}:${dark}}`).join("")} -+.bt-mark{display:inline-block;flex:none;position:relative;line-height:0} -+.bt-mark svg{width:100%;height:100%;overflow:visible} -+.bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} + if (value) { + setKept(value); + return void 0; +@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- + .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} + .bt-mark svg{width:100%;height:100%;overflow:visible} + .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} +.bt-mark-emoji{width:100%;height:100%;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;user-select:none} -+.bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} -+.bt-mark .bt-b{transform-origin:50% 100%} -+.bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} -+/* Motion levels (Settings → Bots → Animation) set body[data-bt-motion]: quiet moves a -+ mark only for its state; normal, the default, adds petting, pokes and eyes that follow -+ the pointer; lively adds blinks and fidgets at rest and a question riding on the head. */ -+body[data-bt-motion=lively] .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{animation:bt-blink-idle 5s linear infinite;animation-delay:var(--bt-delay,0s)} -+body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle]:is([data-fidget=hop],[data-fidget=sway],[data-fidget=stretch]) :is(.bt-b,.bt-mark-img){animation:var(--bt-fidget) 9s ease-in-out var(--bt-fidget-delay,0s) infinite} -+body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=hop]{--bt-fidget:bt-fidget-hop} -+body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=sway]{--bt-fidget:bt-fidget-sway} -+body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=stretch]{--bt-fidget:bt-fidget-stretch} -+body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=look] .bt-eyes{animation:bt-fidget-look 9s ease-in-out var(--bt-fidget-delay,0s) infinite} -+@keyframes bt-fidget-hop{0%,8%,100%{transform:none}3%{transform:translateY(-10%) scale(.97,1.04)}5.5%{transform:translateY(0) scale(1.05,.95)}} -+@keyframes bt-fidget-sway{0%,10%,100%{transform:none}2.5%{transform:rotate(-8deg)}5%{transform:rotate(7deg)}7.5%{transform:rotate(-3deg)}} -+@keyframes bt-fidget-stretch{0%,9%,100%{transform:none}4%{transform:scale(.94,1.08)}7%{transform:scale(1.03,.97)}} -+@keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} -+body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} -+body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} + .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} + .bt-mark .bt-b{transform-origin:50% 100%} + .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} +@@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo + @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} + body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} + body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} +.bt-tree-item{position:relative;display:flex;align-items:center;min-width:0} +.bt-tree-item>.bt-row{flex:1 1 auto;min-width:0} +.bt-tree-toggle{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--bt-ink-3);font-size:16px;line-height:1;cursor:pointer} @@ -3564,83 +3263,14 @@ diff --git a/client.js b/client.js +.bt-tree-chev{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;font-size:16px;line-height:1;transition:transform .12s ease} +.bt-tree-toggle[aria-expanded=false] .bt-tree-chev{transform:rotate(-90deg)} +.bt-tree-leaf{cursor:default} -+@keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} -+/* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its -+ size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -+ closed in a happy arc, and springs back when the pointer leaves. */ -+:is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster)::before{content:'';position:absolute;inset:-20%;border-radius:50%} -+.bt-mark[data-live] .bt-b,.bt-mark[data-live] .bt-mark-img{transition:rotate .5s cubic-bezier(.34,1.56,.64,1),translate .5s cubic-bezier(.34,1.56,.64,1),scale .3s ease-out} -+.bt-mark[data-live] .bt-eye{transition:scale .14s ease-in} -+.bt-mark[data-live] .bt-eye-happy{transition:opacity .12s ease-out} -+body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]):not(.bt-mark-poke) :is(.bt-b,.bt-mark-img){rotate:8deg;translate:5% -1%;animation:bt-nuzzle .78s ease-in-out .32s infinite} -+body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{scale:1 0;animation:none} -+body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye-happy{opacity:1;transition-delay:.1s} -+@keyframes bt-nuzzle{50%{rotate:14deg;translate:8% -3%;scale:1.03 .97}} -+.bt-mark[data-gaze] .bt-b{translate:calc(var(--bt-gx,0) * 5%) calc(var(--bt-gy,0) * 4%);transition:translate .18s ease-out} -+.bt-mark[data-gaze] .bt-gaze{transform:translate(calc(var(--bt-gx,0) * 12%),calc(var(--bt-gy,0) * 11%));transition:transform .18s ease-out} -+body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-b{translate:none} -+body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} -+.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} -+.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} -+.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} -+.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} -+.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} -+.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} -+.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} -+.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} -+.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} -+.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} -+.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} -+/* A question for you hops twice when it arrives and the badge keeps it in view after; -+ a lively mark keeps hopping. */ -+.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} -+.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} -+.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} -+body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} -+.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} -+.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} -+@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} -+.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} -+.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} -+.bt-mark[data-poke]{cursor:pointer} -+body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} -+body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} -+@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} -+@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} -+@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} -+@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} -+@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} -+@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} -+@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} -+@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} -+@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} -+@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} -+@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} -+@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} -+@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} -+/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ -+@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} -+@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} -+@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} -+.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} -+.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} -+.bt-badge-admin svg{width:7px;height:7px} -+.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} -+.bt-badge-main svg{width:100%;height:100%;overflow:visible} -+.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} -+.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} -+.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} -+.bt-cluster>*{position:absolute} -+.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} -+.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} -+.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-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} + @keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} + /* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its + size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes +@@ -1933,15 +1933,23 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + .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} +-.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} +.bt-brand-mark{display:inline-flex;line-height:0} +.bt-flybot-mark{display:block;flex:none} +.bt-flybot-mark .bt-flybot-wing{fill:#8FB2FF} @@ -3649,194 +3279,83 @@ diff --git a/client.js b/client.js +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-wing{fill:#A9C2FF} +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-body{fill:#6E87FF} +[data-ds-dark-theme] .bt-flybot-mark .bt-flybot-eye{fill:#0F1530} -+.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} -+.bt-round:hover{background:var(--bt-hover)} -+.bt-round:active{transform:scale(.94)} -+.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} -+.bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} + .bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} + .bt-round:hover{background:var(--bt-hover)} + .bt-round:active{transform:scale(.94)} + .bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} + .bt-tile-wrap{display:flex;justify-content:center;padding:20px 0 15px} +-.bt-tile{display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} +.bt-tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;width:95px;padding:6px 8px 4px;border-radius:12px;border:0;background:transparent;color:var(--bt-ink);cursor:pointer;font-family:inherit;transition:background-color .12s ease} -+.bt-tile:hover{background:var(--bt-hover)} -+.bt-tile[aria-current=page]{background:var(--bt-active)} + .bt-tile:hover{background:var(--bt-hover)} + .bt-tile[aria-current=page]{background:var(--bt-active)} +.bt-tile-unread{position:absolute;top:8px;right:12px} -+.bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} -+.bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} -+.bt-row:hover{background:var(--bt-hover)} -+.bt-row[aria-current=page]{background:var(--bt-active)} -+.bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} -+.bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} -+.bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-tile-name{font-size:13px;line-height:18px;font-weight:400;max-width:84px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bt-chip{font-size:11px;line-height:16px;padding:1px 4px;border-radius:4px;background:var(--bt-hover);border:1px solid var(--bt-line);color:var(--bt-ink-2);white-space:nowrap;max-width:84px;overflow:hidden;text-overflow:ellipsis} + .bt-row{display:flex;align-items:center;gap:8px;min-height:54px;padding:8px;border:0;border-radius:10px;background:transparent;color:var(--bt-ink);text-align:left;cursor:pointer;width:100%;box-sizing:border-box;font-family:inherit;transition:background-color .12s ease} +@@ -1950,27 +1950,32 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + .bt-row-body{display:flex;flex-direction:column;min-width:0;flex:1 1 auto} + .bt-row-name{display:flex;align-items:center;gap:6px;min-width:0;font-size:14px;line-height:20px;font-weight:400} + .bt-row-title{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.bt-row-title.bt-row-title-face{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3;overflow:hidden} +.bt-row-remote{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2)} -+.bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} -+.bt-unread-wait{background:var(--bt-star)} + .bt-tag{flex:none;max-width:96px;font-size:12px;line-height:16px;font-weight:400;padding:1px 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-row-preview{font-size:13px;line-height:18px;color:var(--bt-ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-unread{width:8px;height:8px;border-radius:50%;background:#1084fe;flex:none;margin-right:2px;animation:bt-fade .13s cubic-bezier(.22,1,.36,1)} + .bt-unread-wait{background:var(--bt-star)} +.bt-shared-from,.bt-shared-wait{display:flex;flex-direction:column;gap:6px;padding:8px 0} +.bt-shared-wait .bt-set-row{min-width:0} +.bt-shared-wait .bt-set-copy{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block} -+.bt-rail{align-items:center;padding:0 0 8px} -+.bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} -+.bt-side-foot{display:flex;padding-top:8px} -+.bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} -+.bt-connect:hover{background:var(--bt-hover)} -+.bt-connect svg{flex:none;width:15px;height:15px} + .bt-rail{align-items:center;padding:0 0 8px} + .bt-rail .bt-row{justify-content:center;padding:6px 0;min-height:44px} + .bt-side-foot{display:flex;padding-top:8px} + .bt-connect{flex:1;height:36px;border-radius:999px;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:background-color .12s ease} + .bt-connect:hover{background:var(--bt-hover)} + .bt-connect svg{flex:none;width:15px;height:15px} +-.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0} +-.bt-connect-warn{flex:none;width:16px;height:16px;border-radius:50%;background:#E5484D;color:#fff;font-size:11px;line-height:16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center} +-.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +.bt-connect-wrap{position:relative;display:flex;flex:1 1 auto;min-width:0}ms ${SPRING_SHAPE.easing}} -+@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} + @keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} +-@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}} +@media (prefers-reduced-motion:reduce){} -+ -+.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} -+.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -+.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} -+.bt-pill:active{transform:scale(.965)} -+.bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} + + .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} + .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} + .bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} + .bt-pill:active{transform:scale(.965)} + .bt-pill-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500} +.bt-pill:has(.bt-pill-name-face){height:auto;min-height:40px;align-items:flex-start} +.bt-pill-name.bt-pill-name-face{min-width:0;white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden} -+.bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} -+.bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} -+.bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} -+.bt-pill-mem-in::before{content:"";flex:none;width:1px;height:14px;margin-right:4px;background:var(--bt-line-2)} -+.bt-pill[data-memory=updated] .bt-pill-mem-in{opacity:1;filter:none;transform:none;transition-delay:70ms} -+.bt-pill-mem-ico{flex:none;display:inline-flex;width:16px;height:16px;color:var(--bt-accent)} -+.bt-pill-mem-ico svg{width:16px;height:16px} -+.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:bt-mem-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 90ms both} -+@keyframes bt-mem-pop{from{transform:scale(.4) rotate(-14deg)}to{transform:none}} -+.bt-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} -+@media (prefers-reduced-motion:reduce){.bt-pill-mem,.bt-pill-mem-in{transition:none!important}.bt-pill-mem-in{filter:none;transform:none}.bt-pill[data-memory=updated] .bt-pill-mem-ico{animation:none}} -+.bt-swap{color:var(--bt-ink-3);font-size:12px} -+.bt-pill-go{flex:none;display:inline-flex;align-items:center;justify-content:flex-end;width:0;margin-left:-8px;overflow:hidden;opacity:0;filter:blur(6px);transform:translateX(-6px);color:var(--bt-ink-2);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},margin-left ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},opacity .18s ease,filter .18s ease} -+.bt-pill-go svg{flex:none;transition:transform ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -+.bt-pill:hover .bt-pill-go,.bt-pill:focus-visible .bt-pill-go,.bt-pill[aria-expanded=true] .bt-pill-go{width:18px;margin-left:0;opacity:1;filter:none;transform:none} -+.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)} -+.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)} -+@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}} -+ -+.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} -+.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} + .bt-pill-mem{flex:none;display:inline-flex;width:0;margin-left:-8px;overflow:hidden;transition:${spring(SPRING_SHAPE, "width", "margin-left")}} + .bt-pill[data-memory=updated] .bt-pill-mem{width:var(--bt-mem-w);margin-left:0;transition:${spring(SPRING_STRETCH, "width", "margin-left")}} + .bt-pill-mem-in{flex:none;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;color:var(--bt-ink-2);font-size:13px;font-weight:500;opacity:0;filter:blur(6px);transform:translateX(-8px);transition:opacity .14s ease,filter .18s ease,${spring(SPRING_SHAPE, "transform")}} +@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + + .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} + .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} +.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)} -+.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} -+.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} -+.bt-bubble [class*="_markdown"]>*+*{margin-top:10px} -+.bt-bubble [class*="_markdown"]{color:inherit!important;font-size:inherit!important;line-height:inherit!important} -+.bt-bubble p{margin:0} -+.bt-bubble p + p{margin-top:6px} -+.bt-bubble ul,.bt-bubble ol{margin:4px 0;padding-left:20px} -+.bt-bubble a[href^="${MENTION_ORIGIN}"]{text-decoration:none;font-weight:400;cursor:pointer;white-space:nowrap} -+.bt-bubble a[href^="${MENTION_ORIGIN}"]::before{content:"";display:inline-block;width:16px;height:16px;margin-right:3px;vertical-align:-3px;background:currentColor;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat} -+.bt-group-msg{display:flex;flex-direction:column;gap:4px;margin:16px 0 0} -+.bt-group-msg:first-child{margin-top:0} -+.bt-lead{flex:none;display:flex;margin-bottom:1px} -+button.bt-lead{border:0;background:none;padding:0;cursor:pointer} -+/* Rows without the avatar start where the bubble beside it starts: 22px avatar + 8px gap. */ -+.bt-astack[data-lead]>.bt-msg:not(:last-child)>.bt-msg-line,.bt-astack[data-lead] .bt-reacts{margin-left:30px} -+.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px} -+.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)} -+ -+.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -+body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)} -+@keyframes bt-q-in{from{transform:translateY(10px) scale(.97)}} -+.bt-q-settled{animation:none} -+.bt-q-head{display:flex;align-items:flex-start;gap:8px;min-width:0} -+.bt-q-text{flex:1 1 0;min-width:0;display:flex;flex-direction:column} -+.bt-q-title{margin:0;font-size:15px;line-height:22px;font-weight:600;overflow-wrap:anywhere} -+.bt-q-detail{font-size:13px;line-height:18px;color:var(--bt-ink-2)} -+.bt-q-x{flex:none;width:20px;height:20px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-q-x svg{width:14px;height:14px} -+.bt-q-x:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-q-x:focus-visible,.bt-q-opt:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} -+.bt-q-list{display:flex;flex-direction:column;gap:1px;min-width:0;box-sizing:border-box;margin:0 -8px} -+.bt-q-opt{position:relative;display:flex;align-items:flex-start;gap:16px;width:100%;min-width:0;box-sizing:border-box;margin:0;border:0;border-radius:12px;background:transparent;padding:10px 8px;font:inherit;color:var(--bt-ink);text-align:start;cursor:pointer;transition:background-color .12s ease} -+.bt-q:not(.bt-q-raised)>.bt-q-head:has(+.bt-q-list){padding-bottom:12px;border-bottom:1px solid var(--bt-q-rule)} -+.bt-q:not(.bt-q-raised)>.bt-q-head+.bt-q-list{margin-top:-6px} -+.bt-q-opt+.bt-q-opt::before{content:"";position:absolute;left:calc(var(--bt-key,24px) + 24px);right:8px;top:-1px;height:1px;background:var(--bt-q-rule-2);transition:opacity .12s ease;pointer-events:none} -+button.bt-q-opt:hover::before,button.bt-q-opt:hover+.bt-q-opt::before,.bt-q-opt[aria-pressed=true]::before,.bt-q-opt[aria-pressed=true]+.bt-q-opt::before{opacity:0} -+.bt-q-raised .bt-q-opt::before{display:none} -+button.bt-q-opt:hover{background:var(--bt-hover)} -+button.bt-q-opt:active{background:var(--bt-active)} -+.bt-q-opt[aria-pressed=true]{background:var(--bt-active)} -+.bt-q:not(.bt-q-raised) :is(button.bt-q-opt:hover,.bt-q-opt[aria-pressed=true]) .bt-key{background:var(--bt-card);color:var(--bt-ink)} -+.bt-key{flex:none;box-sizing:border-box;width:var(--bt-key,24px);height:var(--bt-key,24px);border-radius:8px;background:var(--bt-hover);color:var(--bt-ink-2);font-size:12px;line-height:var(--bt-key,24px);font-weight:500;text-align:center;font-variant-numeric:tabular-nums;transition:background-color .12s ease,color .12s ease} -+.bt-q-body{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;padding-top:2px} -+/* With a description the key spans the two text lines less 2px above and below. */ -+.bt-q-opt:has(.bt-q-desc){--bt-key:34px} -+.bt-q-opt:has(.bt-q-desc) .bt-key{margin-top:2px;border-radius:10px;font-size:13px} -+.bt-q-opt:has(.bt-q-desc) .bt-q-body{padding-top:0} -+.bt-q-label{font-size:14px;line-height:20px;font-weight:500;white-space:normal;overflow-wrap:anywhere} -+.bt-q-desc{font-size:12px;line-height:17px;color:var(--bt-ink-2);overflow-wrap:anywhere} -+.bt-q-check{flex:none;display:inline-flex;align-items:center;color:var(--bt-ink-2);animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} -+.bt-q-settled .bt-q-check{animation:none} -+.bt-q-check svg{width:16px;height:16px} -+.bt-q-settled .bt-key{opacity:.4} -+.bt-q-settled .bt-q-label{color:color-mix(in srgb,var(--bt-ink) 45%,transparent)} -+.bt-q-dismissed .bt-q-title{color:var(--bt-ink-2)} -+.bt-q.bt-q-settled{gap:6px;padding:12px 16px} -+.bt-q-settled .bt-q-title{font-size:14px;line-height:20px;font-weight:500} -+.bt-q.bt-q-settled>.bt-q-head:has(+.bt-q-list){padding-bottom:0;border-bottom:0} -+.bt-q.bt-q-settled>.bt-q-head+.bt-q-list{margin-top:0} -+.bt-q-settled .bt-q-opt{padding:4px 8px} -+.bt-q-badge{flex:none;height:20px;padding:0 7px;border-radius:999px;border:1px dotted var(--bt-line-2);color:var(--bt-ink-2);font-size:12px;line-height:18px;box-sizing:border-box} -+.bt-q-custom{display:flex;align-items:flex-start;gap:8px;width:100%;min-width:0} -+.bt-q-field{flex:1 1 0;min-width:0;display:flex;box-sizing:border-box;min-height:32px;padding:5px 11px;border-radius:8px;border:.5px solid var(--bt-line-2);background:none;cursor:text;transition:border-color .12s ease} -+.bt-q-field:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)} -+.bt-q-input{flex:1;width:100%;min-width:0;max-height:120px;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;resize:none;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere} -+.bt-q-input:not(:placeholder-shown){field-sizing:content} -+.bt-q-input::placeholder{color:var(--bt-ink-3)} -+.bt-q-submit,.bt-q-btn{flex:none;height:36px;padding:0 14px;border:0;border-radius:999px;font:inherit;font-size:14px;font-weight:500;cursor:pointer;transition:opacity .12s ease,background-color .12s ease,${spring(SPRING_TAP, "scale")}} -+.bt-q-submit{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards,bt-veil-in .16s ease-out backwards} -+@keyframes bt-q-submit-in{from{transform:translateX(8px) scale(.9)}} -+.bt-q-submit:hover:not(:disabled){opacity:.86} -+.bt-q-submit:disabled{opacity:.4;cursor:default} -+.bt-q-btn{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-q-btn:hover{background:var(--bt-active)} -+.bt-q-foot{display:flex;justify-content:flex-end;gap:8px} -+.bt-q-raised{width:calc(var(--dsh-chat-content-width,748px) - 32px);max-width:calc(100% - 32px);gap:0;padding:0;box-shadow:0 2px 8px rgba(20,20,20,.035),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 5%,transparent)} -+.bt-q-raised .bt-q-head{padding:14px 16px 4px 20px} -+.bt-q-raised .bt-q-head:last-child{padding-bottom:16px} -+.bt-q-raised .bt-q-list{gap:1px;margin:0;padding:8px} -+.bt-q-raised .bt-q-opt{gap:4px;padding:7px;border-radius:16px;color:var(--bt-ink-2)} -+.bt-q-raised button.bt-q-opt:hover{color:var(--bt-ink)} -+.bt-q-raised .bt-q-opt[aria-pressed=true]{background:transparent;color:var(--bt-ink)} -+.bt-q-raised .bt-q-opt[aria-pressed=true]:hover{background:var(--bt-hover)} -+.bt-q-raised .bt-q-opt[aria-pressed=true] .bt-key,.bt-q-ownrow:focus-within .bt-key,.bt-q-ownrow[data-filled] .bt-key{background:var(--bt-user);color:var(--bt-user-ink)} -+.bt-q-raised .bt-q-body{padding:2px 6px 0} -+.bt-q-raised .bt-q-desc{color:color-mix(in srgb,var(--bt-ink) 61%,transparent)} -+.bt-q-ownrow{cursor:text} -+.bt-q-ownrow .bt-q-input{color:var(--bt-ink)} -+.bt-q-raised .bt-q-foot{padding:0 8px 8px} -+@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} -+ -+.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} + .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} + .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} + .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} +@@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} + @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} + + .bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} +.bt-pane[data-menu]{background:transparent}.bt-pane[data-menu] .bt-newchat{flex:none;align-self:flex-start;width:min(280px,calc(100% - 16px));max-height:calc(100% - 16px);margin:8px;overflow:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 10px 30px rgba(0,0,0,.14),0 2px 6px rgba(0,0,0,.06)}.bt-newchat[data-menu] .bt-drop{position:relative;top:0;left:0;width:auto;max-height:none;margin:0;box-shadow:none;border:0;max-width:none} -+.bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} -+.bt-to-label{color:var(--bt-ink-2)} -+.bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} -+.bt-token{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 6px 0 4px;border-radius:999px;background:var(--bt-hover);font-size:13px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-token button{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;padding:0 2px} -+.bt-admin-star{flex:none;display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border-radius:50%;corner-shape:round;background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar));vertical-align:-1px} -+.bt-to-star{display:inline-flex;margin-right:-2px;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -+.bt-to .bt-to-create{flex:none;height:28px;padding:0 12px;font-size:13px;animation:bt-q-submit-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} -+.bt-drop{position:absolute;top:48px;left:32px;width:min(550px,calc(100% - 64px));display:flex;flex-direction:column;padding:6px;max-height:min(420px,60vh);overflow-y:auto;background:var(--bt-card);border:1px solid var(--bt-line-2);border-radius:12px;box-shadow:0 6px 20px -4px rgba(0,0,0,.12);box-sizing:border-box;z-index:1;transform-origin:24px 0;animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} -+.bt-option{display:flex;align-items:center;gap:10px;border:0;background:none;border-radius:8px;padding:7px 8px;min-height:36px;box-sizing:border-box;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;text-align:left;width:100%} -+.bt-option[aria-selected=true]{background:var(--bt-active)} -+.bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} -+.bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} + .bt-to-label{color:var(--bt-ink-2)} + .bt-to input{flex:1;min-width:120px;border:0;outline:none;background:none;color:var(--bt-ink);font-size:14px;height:26px} +@@ -2098,7 +2098,9 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-option[aria-selected=true]{background:var(--bt-active)} + .bt-drop-list>.bt-option:not([aria-selected=true]):hover{background:var(--bt-hover)} + .bt-option-label{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +-.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:none;animation:bt-veil-in .16s ease-out} +.bt-option-hint{font-size:11px;color:var(--bt-ink-2);flex:0 1 auto;min-width:0;max-width:50%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;animation:bt-veil-in .16s ease-out} +.bt-option-copy{flex:1;min-width:0;display:flex;flex-direction:column} +.bt-option-sub{font-size:11px;line-height:15px;color:var(--bt-ink-2);white-space:normal;overflow-wrap:anywhere} -+.bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} -+.bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} -+.bt-nc-spacer{flex:1} -+.bt-nc-compose{padding:0 16px 14px;display:flex;justify-content:center} -+.bt-nc-card{display:flex;align-items:flex-end;gap:6px;width:100%;max-width:var(--dsh-chat-content-width,748px);min-height:44px;padding:4px 6px;border-radius:22px;border:1px solid var(--bt-line-2);background:var(--bt-main);box-shadow:0 2px 8px -2px rgba(0,0,0,.06);box-sizing:border-box} -+.bt-nc-plus{width:34px;height:34px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2);background:var(--bt-hover);flex:none} -+.bt-nc-card textarea{flex:1;resize:none;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;padding:7px 4px;min-height:34px;max-height:140px;box-sizing:border-box} -+.bt-nc-send{width:34px;height:34px;border-radius:50%;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;flex:none} -+.bt-nc-send:disabled{opacity:.3;cursor:default} -+.bt-create{flex:1;display:flex;flex-direction:column;min-width:0} - .bt-create-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);box-sizing:border-box} - .bt-create-gap{width:28px} - .bt-create-title{font-size:14px;line-height:20px;font-weight:500} + .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} + .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} + .bt-nc-spacer{flex:1} @@ -2127,6 +2127,10 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} @@ -4141,309 +3660,81 @@ diff --git a/client.js b/client.js .bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} .bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} .bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} -@@ -2676,2104 +2676,4297 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} - [data-bt-enter="new"]{animation:none} - [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} - } --@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} +@@ -2679,7 +2679,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + @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; -+@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{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 55%,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-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} + [class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} + [class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +@@ -2691,6 +2691,8 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + [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} +/* A friend's Bot chat: no local run stats (they are relay turns, always 0; #419). */ +body[data-bt-friend-chat] [data-composer-stats]{display:none!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} + [data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} + /* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ + [data-composer-card] button[class*="_add"]{display:none!important} +@@ -2722,7 +2722,9 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} + .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] [class*="_activity"]{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)} + 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)} +body[data-bt-workflow] [data-composer-card]{display:none!important} -+.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-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} + [data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} + .bt-q{order:1} +@@ -2776,6 +2776,7 @@ div:has(> nav [class*="_marks"]){display:none!important} + .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-urow>.bt-lead{align-self:flex-end;margin-left:6px} -+.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; -+} -+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); -+ } -+ }; -+ try { -+ visit(sheet.cssRules); -+ } catch { -+ } -+ 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); -+ } -+ return compiled.get(fragment); -+ }); -+} -+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); -+ } -+ 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(); -+ }; -+} -+ + .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} +@@ -2855,1925 +2855,4195 @@ function installCompiledStyles(text, tag) { + }; + } + +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +function isGenericFaceTitle(name) { @@ -4711,15 +4002,7 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("circle", { className: "bt-flybot-body", cx: 256, cy: 100, r: 22 }), + (0, import_react5.createElement)("rect", { className: "bt-flybot-body", x: 248, y: 104, width: 16, height: 34 }) + ); - } --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); ++} + +// src/client/questions.js +var import_react6 = require("react"); @@ -4743,11 +4026,37 @@ diff --git a/client.js b/client.js + event.preventDefault(); + ownRef.current.focus(); + } -+ }; + }; +- }, [enabled]); + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [count, ownRef]); -+} + } +-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 QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { + const [own, setOwn] = (0, import_react6.useState)(""); + const [picked, setPicked] = (0, import_react6.useState)([]); @@ -4761,12 +4070,18 @@ diff --git a/client.js b/client.js + if (multi) { + setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); + return; - } ++ } + onAnswer({ selected: [label] }); }; -- try { -- visit(sheet.cssRules); -- } catch { +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +-} +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) + const submit = () => { + if (!canSubmit) return; + onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); @@ -4830,8 +4145,7 @@ diff --git a/client.js b/client.js + ); + } else if ((raised || multi) && canSubmit) { + foot = (0, import_react6.createElement)("div", { className: "bt-q-foot" }, (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); - } -- return names; ++ } + return (0, import_react6.createElement)( + "form", + { @@ -4862,18 +4176,16 @@ diff --git a/client.js b/client.js + !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null + ) : null, + foot -+ ); + ); } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); -- }); +-function 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 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 SettledQuestion({ question, answer }) { + const options = optionsOf(question); + const prompt = question?.question ?? ""; @@ -4904,21 +4216,17 @@ diff --git a/client.js b/client.js + (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) + ); + })) -+ ); + ); } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); +-function 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; +function QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; @@ -4933,109 +4241,35 @@ diff --git a/client.js b/client.js + setIndex(index + 1); + return; } -- 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 (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + pending.answer({ answers: next }); - }; ++ }; + return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); --} --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 +-function 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 PendingQuestion({ sessionId, question, actions, inline }) { + const [answered, setAnswered] = (0, import_react6.useState)(null); + if (answered === question.id) return null; + const onAnswer = (answer) => { + setAnswered(question.id); + void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); - }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); --} --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++ }; + const onDismiss = () => { + setAnswered(question.id); + void actions.dismissQuestion(sessionId); @@ -5046,8 +4280,16 @@ diff --git a/client.js b/client.js + (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ); } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- } +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; +function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { + const question = useRoster((value) => value.questions?.[sessionId]); + const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; @@ -5055,41 +4297,48 @@ diff --git a/client.js b/client.js + if (question === void 0 || question.callId && (drawn || loading)) return null; + return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); +-function 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 AnsweredCell({ node }) { + const data = node.data ?? {}; + const questions = Array.isArray(data.questions) ? data.questions : []; + if (questions.length === 0) { + return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -+ } + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) + const answers = Array.isArray(data.answers) ? data.answers : []; + return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { + key: question.id, + question, + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); - } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; -- } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; ++} + +// src/client/secret-card.js +var import_react7 = require("react"); @@ -5145,19 +4394,7 @@ diff --git a/client.js b/client.js + onEnter?.(); + } + }); - } --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null ++} +function ScopeSwitch({ value, onChange, bot }) { + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react7.createElement)( @@ -5174,47 +4411,11 @@ diff --git a/client.js b/client.js + }, text)) ); } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); -- } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) +- +-// 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 SecretHead({ bot, title, detail, name, onClose }) { + return (0, import_react7.createElement)( + "div", @@ -5227,14 +4428,8 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("div", { className: "bt-q-detail" }, (0, import_react7.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") + ), + onClose ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react7.createElement)(CloseIcon)) : null - ); ++ ); } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} -function SearchIcon() { - return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +function SecretCard({ bot, request, roster, actions }) { @@ -5701,11 +4896,11 @@ diff --git a/client.js b/client.js - return; - } - onAnswer({ selected: [label] }); - }; +- }; - const submit = () => { - if (!canSubmit) return; - onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; + }; - useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); - const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; - const field = ownAnswer ? (0, import_react4.createElement)("textarea", { @@ -5905,13 +5100,8 @@ diff --git a/client.js b/client.js - ) : null, - foot + error ? (0, import_react8.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null -+ ); -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, -+ custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain ); - } +-} -function SettledQuestion({ question, answer }) { - const options = optionsOf(question); - const prompt = question?.question ?? ""; @@ -5929,6 +5119,17 @@ diff --git a/client.js b/client.js - ); - } - return (0, import_react4.createElement)( ++ return (0, import_react8.createElement)( + "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( ++ { className: "bt-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/turn-reasoning.js +function turnReasoning(steps, current) { @@ -5955,12 +5156,7 @@ diff --git a/client.js b/client.js + }; + const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; + return (0, import_react9.createElement)( - "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( ++ "div", + { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, + texts2.map((text, index) => { + const key = msgKey ? `${msgKey}:${index}` : void 0; @@ -6201,6 +5397,7 @@ diff --git a/client.js b/client.js - event.preventDefault(); - onEnter?.(); - } +- }); +var onlyMarkup = (lines) => lines.length === 1 ? t("{n} line of reasoning", { n: lines.length }) : t("{n} lines of reasoning", { n: lines.length }); +var peekLine = (text) => { + const lines = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).filter((part) => part !== ""); @@ -6222,7 +5419,10 @@ diff --git a/client.js b/client.js + ), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) + ); -+} + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -6288,7 +5488,17 @@ diff --git a/client.js b/client.js + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); + const toName = cleanPeerName(held.toOwner) || (chat ? friendBotOwner(chat) : ownerNameOf({ handle: held.to })) || held.to || t("a friend"); + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null @@ -6341,11 +5551,8 @@ diff --git a/client.js b/client.js + if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); - }); - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( ++ }); ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -6372,7 +5579,10 @@ diff --git a/client.js b/client.js + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + (0, import_react9.createElement)(BotMark, { bot, size: 22 }) -+ ); + ); +-} +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( + const name = (0, import_react9.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -6381,27 +5591,14 @@ diff --git a/client.js b/client.js + ); + return (0, import_react9.createElement)( "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -+ { className: "bt-group-msg" }, -+ name, -+ (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", - { className: "bt-q-head bt-secret-head" }, - (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), - (0, import_react5.createElement)( ++ { className: "bt-group-msg" }, ++ name, ++ (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ); ++} +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6708,7 +5905,6 @@ 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 TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6812,9 +6008,7 @@ diff --git a/client.js b/client.js +} +function Nothing() { + return null; - } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); ++} +var 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([ @@ -6868,14 +6062,7 @@ diff --git a/client.js b/client.js + } + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); ++} +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -6892,20 +6079,9 @@ diff --git a/client.js b/client.js + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return null; ++ } + return next === void 0 ? void 0 : turns[next].first; - } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++} +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -6914,39 +6090,7 @@ 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 ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); ++} +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; @@ -6955,35 +6099,7 @@ diff --git a/client.js b/client.js + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; - } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.useState)(false); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!long) return; -- void Promise.resolve(onSave(value)).then((ok) => { -- if (ok) { -- setValue(""); -- setReplaced(true); -- } -- }); -- }; -- const edit = (next) => { -- setValue(next); -- setReplaced(false); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -- ), -- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null ++} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -7009,79 +6125,18 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-control" }, -- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) -- ) -- ), -- open ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) -- ) -- ) : null -- ); +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); } --function AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -- ) -- ); +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], + working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] @@ -7138,126 +6193,68 @@ 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) }; -+ } + } +- return null; } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -- ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); } --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; -- --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); -+function workText(tool, step) { -+ return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; - } --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, +- type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) +- ); ++function workText(tool, step) { ++ return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; + } +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.useState)(false); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!long) return; +- void Promise.resolve(onSave(value)).then((ok) => { +- if (ok) { +- setValue(""); +- setReplaced(true); +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react9.useEffect)(() => { @@ -7281,7 +6278,14 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); -+ } + } +- }); +- }; +- const edit = (next) => { +- setValue(next); +- setReplaced(false); +- }; +- return (0, import_react5.createElement)( + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; + void tick(); @@ -7302,16 +6306,15 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) +- ), +- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7323,29 +6326,14 @@ diff --git a/client.js b/client.js + return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react9.createElement)( - "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( ++ "div", + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react9.createElement)( - "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) ++ "div", + { className: "bt-ububble" }, + (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) - ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); ++ ), + room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null + ); + } @@ -7406,21 +6394,51 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }) ++ }) ); } --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(); +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-control" }, +- secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) +- ) +- ), +- open ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) +- ) +- ) : null +- ); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { @@ -7435,7 +6453,18 @@ diff --git a/client.js b/client.js + }; + visit(root); + return calls; -+} + } +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- onDone(); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7505,6 +6534,22 @@ diff --git a/client.js b/client.js + } catch { + parsed = null; } +- }); +- }; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) +- ) +- ); + const answer = parsed?.answers?.[0]; + const question = argValue("questions")?.[0]; + if (!answer) return null; @@ -7521,7 +6566,30 @@ diff --git a/client.js b/client.js + default: + return null; + } -+} + } +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; @@ -7537,16 +6605,133 @@ diff --git a/client.js b/client.js + if (drawn.length === 0) return null; + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); -+} -+ + } +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +-`; + +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; -+} -+ + } +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); + +-// src/client/cells.js +-var import_react6 = require("react"); +-var import_react_dom = require("react-dom"); +-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); +- }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) +- ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null +- ); +- }) +- ); +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { @@ -7563,7 +6748,20 @@ diff --git a/client.js b/client.js + if (typeof root.matches === "function" && root.matches("[data-chat-running]")) return root; + if (typeof Element !== "undefined" && root instanceof Element) return root.querySelector("[data-chat-running]"); + return root.querySelector(RUNNING_HOST); -+} + } +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } + +// src/client/activity-row.js +var import_react10 = require("react"); @@ -8234,32 +7432,21 @@ diff --git a/client.js b/client.js - 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 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 Nothing() { -- return null; ++} +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 }] : []; -+ }); + }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : 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 Nothing() { +- return null; +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -8268,29 +7455,14 @@ diff --git a/client.js b/client.js + 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 nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -- } -- return next === void 0 ? void 0 : turns[next].first; ++} +function 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 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; ++} +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)}`; @@ -8303,29 +7475,16 @@ diff --git a/client.js b/client.js + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : 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 +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -8341,10 +7500,13 @@ diff --git a/client.js b/client.js + doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null + ), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); ++ ); } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -8367,7 +7529,17 @@ diff --git a/client.js b/client.js + rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); -+ } + } +- return next === void 0 ? void 0 : turns[next].first; +-} +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; + return (0, import_react12.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react12.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -8379,18 +7551,30 @@ diff --git a/client.js b/client.js + onOpen: () => actions.openSession(item.room.id) + }))); } --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 unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +- ); +function useFlowColumn(ref, active, key) { + (0, import_react12.useLayoutEffect)(() => { + const node = ref.current; @@ -8407,7 +7591,9 @@ diff --git a/client.js b/client.js + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); -+} + } +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -8416,7 +7602,19 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [active]); + return now; -+} + } +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +-}; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], @@ -8539,6 +7737,20 @@ diff --git a/client.js b/client.js - (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(); +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} @@ -8619,20 +7831,7 @@ diff --git a/client.js b/client.js + (0, import_react13.useEffect)(() => { + taskListeners.add(setTasks); + let stopped = 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(); ++ let timer; + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { + if (!stopped && list !== void 0) publishTasks(list); + }).catch(() => { @@ -9641,6 +8840,32 @@ diff --git a/client.js b/client.js + for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + return columns; +} ++function projectScope(projectId, roster) { ++ const botIds = /* @__PURE__ */ new Set(); ++ const roomIds = /* @__PURE__ */ new Set(); ++ if (!projectId) return { botIds, roomIds }; ++ for (const bot of roster?.bots ?? []) if (bot?.projectId === projectId) botIds.add(bot.id); ++ for (const room2 of roster?.rooms ?? []) if (room2?.projectId === projectId) roomIds.add(room2.id); ++ return { botIds, roomIds }; ++} ++function inProject(task, scope) { ++ if (!scope) return false; ++ if (task?.ownerBotId && scope.botIds.has(task.ownerBotId)) return true; ++ const roomId = task?.origin?.roomId; ++ return Boolean(roomId && scope.roomIds.has(roomId)); ++} ++var DELIVERED_MS = 24 * 60 * 60 * 1e3; ++function projectColumns(tasks, now = Date.now()) { ++ const columns = { inFlight: [], waiting: [], delivered: [] }; ++ for (const task of tasks ?? []) { ++ const status = task?.status; ++ if (status === "open" || status === "claimed" || status === "working") columns.inFlight.push(task); ++ else if (status === "result-ready" || status === "blocked" && task.waitingFor === "input") columns.waiting.push(task); ++ else if (status === "accepted" && typeof task.updatedAt === "number" && now - task.updatedAt <= DELIVERED_MS) columns.delivered.push(task); ++ } ++ for (const list of Object.values(columns)) list.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); ++ return columns; ++} +function columnTree(tasks) { + const ids = new Set((tasks ?? []).map((task) => task.id)); + const childrenOf = /* @__PURE__ */ new Map(); @@ -9759,11 +8984,12 @@ diff --git a/client.js b/client.js +} +var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; +var without = (list, id) => list.filter((item) => item.id !== id); -+function useBoardTasks(actions, scope) { ++function useBoardTasks(actions, scope, roster) { + const [own, setOwn] = (0, import_react15.useState)([]); + const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); + const [archive, setArchive] = (0, import_react15.useState)([]); -+ const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; ++ const [projects, setProjects] = (0, import_react15.useState)([]); ++ const key = scope?.kind === "group" ? `group:${scope.roomId}` : scope?.kind === "project" ? `project:${scope.projectId}` : "own"; + (0, import_react15.useEffect)(() => { + let stopped = false; + const load = () => { @@ -9778,6 +9004,11 @@ diff --git a/client.js b/client.js + void Promise.resolve(actions?.tasks?.({ ...archiveQuery, archive: true, limit: ARCHIVE_SHOWN })).then((list) => { + if (stopped || !Array.isArray(list)) return; + setArchive(list.filter((task) => columnOf(task) === "done")); ++ }).catch(() => void 0); ++ void Promise.resolve(actions?.projects?.()).then((value) => { ++ if (stopped) return; ++ const list = Array.isArray(value?.projects) ? value.projects : Array.isArray(value) ? value : []; ++ setProjects(list); + }).catch(() => void 0); }; - const observer = new MutationObserver(() => { @@ -9796,15 +9027,22 @@ diff --git a/client.js b/client.js - }, [active]); - return host; + }, [key]); -+ const tasks = scope?.kind === "group" ? groupTasks : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ const projectRows = (projects ?? []).flatMap((project) => { ++ if (!project || project.status !== void 0 && project.status !== "active") return []; ++ const members = projectScope(project.id, roster); ++ const memberCount2 = members.botIds.size + members.roomIds.size; ++ return memberCount2 > 0 ? [{ kind: "project", id: project.id, name: project.name ?? project.id, memberCount: memberCount2 }] : []; ++ }); ++ const projectSet = scope?.kind === "project" ? projectScope(scope.projectId, roster) : null; ++ const tasks = scope?.kind === "group" ? groupTasks : scope?.kind === "project" ? own.filter((task) => inProject(task, projectSet)) : own.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); + const replace = (task) => { + const done2 = columnOf(task) === "done"; + if (scope?.kind === "group") setGroupTasks((list) => done2 ? without(list, task.id) : upsert(list, task)); + else setOwn((list) => done2 ? without(list, task.id) : upsert(list, task)); + setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id)); + }; -+ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); -+ return { own, tasks, archive: done, replace }; ++ const done = scope?.kind === "group" ? archive : scope?.kind === "project" ? archive.filter((task) => inProject(task, projectSet)) : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId); ++ return { own, tasks, archive: done, replace, projects: projectRows }; } -function useRowEntrances(replacing) { - const check = (0, import_react7.useRef)(replacing); @@ -9852,7 +9090,7 @@ diff --git a/client.js b/client.js + "data-selected": selected || void 0, + "aria-current": selected ? "true" : void 0, + style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, -+ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) ++ onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : row.kind === "project" ? { kind: "project", projectId: row.id } : { kind: "bot", botId: row.id }) + }, + (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), + meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null @@ -9911,8 +9149,33 @@ diff --git a/client.js b/client.js + onDragEnd: () => onDrag?.(null) + }); +} -+function cardProps(task, roster, onMove, onDrag, partOf2) { -+ return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; ++function cardProps(task, roster, onMove, onDrag, partOf2, readOnly = false) { ++ return { ++ task, ++ roster, ++ movable: readOnly ? false : canMoveTask(roster, task), ++ onMove: readOnly ? void 0 : onMove, ++ onDrag: readOnly ? void 0 : onDrag, ++ partOf: partOf2 ++ }; ++} ++function ProjectColumn({ column, title, tasks, roster }) { ++ return (0, import_react15.createElement)( ++ "section", ++ { ++ className: "bt-board-column", ++ "data-project-column": column, ++ "aria-label": title ++ }, ++ (0, import_react15.createElement)( ++ "h3", ++ { className: "bt-board-column-head" }, ++ (0, import_react15.createElement)("span", null, title), ++ (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) ++ ), ++ tasks.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, ++ tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, void 0, void 0, "", true) })) ++ ); +} +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { + const tree = lanes ? columnTree(tasks) : null; @@ -10047,16 +9310,26 @@ diff --git a/client.js b/client.js - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; - return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { -+ const columns = boardColumns(tasks); ++ const project = scope?.kind === "project" ? scope.projectId : ""; ++ const columns = project ? projectColumns(tasks) : boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; ++ const projectRows = rail.projects ?? []; + return (0, import_react15.createElement)( + "div", + { className: "bt-board-body" }, + (0, import_react15.createElement)( + "aside", + { className: "bt-board-rail", "aria-label": t("Task board") }, ++ projectRows.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Project")) : null, ++ projectRows.map((row) => (0, import_react15.createElement)(RailButton, { ++ key: row.id, ++ row, ++ selected: row.id === project, ++ onSelect, ++ meta: t("{count} members", { count: row.memberCount }) ++ })), + (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), + rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + const count = counts2?.[row.id] ?? { done: 0, total: 0 }; @@ -10088,7 +9361,13 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)( + "div", + { className: "bt-board-columns" }, -+ ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { ++ project ? [["inFlight", t("In flight")], ["waiting", t("Waiting on you")], ["delivered", t("Delivered today")]].map(([column, title]) => (0, import_react15.createElement)(ProjectColumn, { ++ key: column, ++ column, ++ title, ++ tasks: columns[column], ++ roster ++ })) : ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { + key: column, + column, + tasks: columns[column], @@ -10120,18 +9399,20 @@ diff --git a/client.js b/client.js + wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null + ); +} -+function scopeName(roster, scope) { ++function scopeName(roster, scope, projects) { + if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); + if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; ++ if (scope?.kind === "project") return (projects ?? []).find((row) => row.id === scope.projectId)?.name ?? ""; + return ""; +} +function KanbanDialog({ roster, actions, leaving = false }) { -+ const rail = boardRail(roster); -+ const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); ++ const baseRail = boardRail(roster); ++ const [scope, setScope] = (0, import_react15.useState)(() => baseRail.bots[0] ? { kind: "bot", botId: baseRail.bots[0].id } : baseRail.groups[0] ? { kind: "group", roomId: baseRail.groups[0].id } : null); + const [dragging, setDragging] = (0, import_react15.useState)(null); + const [error, setError] = (0, import_react15.useState)(""); + const [draft, setDraft] = (0, import_react15.useState)(""); -+ const { own, tasks, archive, replace } = useBoardTasks(actions, scope); ++ const { own, tasks, archive, replace, projects: projectRows } = useBoardTasks(actions, scope, roster); ++ const rail = { ...baseRail, projects: projectRows }; + const boardTasks = boardTasksFor(tasks, archive); + const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); + useEscape(close); @@ -10144,6 +9425,7 @@ diff --git a/client.js b/client.js + if (columnOf(task) === "done") bucket.done += 1; + } + const move = async (id, column) => { ++ if (scope?.kind === "project") return; + const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); + if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; + setError(""); @@ -10164,7 +9446,7 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } + }; -+ const name = scopeName(roster, scope); ++ const name = scopeName(roster, scope, rail.projects); + return (0, import_react15.createElement)( "div", - { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, @@ -20859,7 +20141,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,31 @@ function apply(ctx) { +@@ -13819,15 +13819,33 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -20881,6 +20163,8 @@ diff --git a/client.js b/client.js + setBotConnector: (botId, connector, enabled) => after(call("bot-connectors", { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), + tasks: (payload) => call("tasks", payload ?? {}), ++ // The Project record and its bindings (#1691); the board reads the list to name its rail (#1701). ++ projects: () => call("projects"), + taskUpdate: (payload) => call("task-update", payload), + taskCreate: (payload) => call("task-create", payload), schedules: () => call("schedules"), diff --git a/patches/ds-bot/files/test/board-project.test.js b/patches/ds-bot/files/test/board-project.test.js new file mode 100644 index 000000000..f1bdf378a --- /dev/null +++ b/patches/ds-bot/files/test/board-project.test.js @@ -0,0 +1,276 @@ +// Project board, slice 1 (#1701): one read-only Project view over the task records. +// The project identity is the durable Project record (#1695): a Bot or a group names a +// project on its own record, the roster already carries `projectId`, and this file's +// board only narrows the one task read by that binding — no second store, no endpoint. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const kanbanPath = fileURLToPath(new URL('../src/client/kanban.js', import.meta.url)) +const source = readFileSync(kanbanPath, 'utf8') + +const bundle = await build({ + entryPoints: [kanbanPath], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], +}) +const module = { exports: {} } +const states = [] +const effects = [] +runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, setInterval: () => 0, clearInterval: () => {}, Date, Promise, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [states.length > 0 ? states.shift() : (typeof initial === 'function' ? initial() : initial), () => {}], + useEffect: fn => { effects.push(fn) }, useCallback: fn => fn, useRef: current => ({ current }), + }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +}) +const { KanbanDialog, boardRail, projectColumns, projectScope } = module.exports + +const expand = (node) => { + if (Array.isArray(node)) return node.flatMap(expand) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type({ ...node.props, children: node.children })) + return { ...node, children: node.children.map(expand) } +} +const all = (node, out = []) => { + if (Array.isArray(node)) { for (const item of node) all(item, out); return out } + if (node && typeof node === 'object') { out.push(node); all(node.children, out) } + return out +} +const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? node.children.map(textOf).join('') : '') +const byClass = (tree, name) => all(tree).filter(node => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name)) + +// Two projects, each with one Bot and one group; two Bots and one group stay unbound. +const HOURS = 3_600_000 +const rosterWithProjects = () => { + const roster = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1, projectId: 'project-rocket' }, + { id: 's', name: 'Writer', ownerId: 'm1', createdBy: 'a', createdAt: 2, projectId: 'project-rocket' }, + { id: 'b', name: 'Other', ownerId: 'm1', createdBy: 'a', createdAt: 3, projectId: 'project-garden' }, + { id: 'c', name: 'Loose', ownerId: 'm1', createdAt: 4 }, + ], + rooms: [ + { id: 'war', name: 'War', admin: 'a', projectId: 'project-rocket', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + { id: 'garden', name: 'Garden', admin: 'a', projectId: 'project-garden', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + roster.byId = Object.fromEntries(roster.bots.map(bot => [bot.id, bot])) + roster.roomsById = Object.fromEntries(roster.rooms.map(room => [room.id, room])) + return roster +} +const unboundRoster = () => { + const roster = { + self: { memberId: 'm1' }, + bots: [ + { id: 'a', name: 'Chief', ownerId: 'm1', createdAt: 1 }, + { id: 's', name: 'Writer', ownerId: 'm1', createdBy: 'a', createdAt: 2 }, + ], + rooms: [ + { id: 'war', name: 'War', admin: 'a', participants: [{ kind: 'human', id: 'm1', role: 'member' }] }, + ], + } + roster.byId = Object.fromEntries(roster.bots.map(bot => [bot.id, bot])) + roster.roomsById = Object.fromEntries(roster.rooms.map(room => [room.id, room])) + return roster +} +const projects = [ + { id: 'project-rocket', name: 'Rocket', status: 'active' }, + { id: 'project-garden', name: 'Garden', status: 'active' }, + { id: 'project-archive', name: 'Old', status: 'archived' }, +] + +const task = (id, status, extra) => ({ id, status, title: `Task ${id}`, ownerBotId: 'a', priority: 'normal', progress: 0, updatedAt: 1, ...extra }) + +// KanbanDialog's preset states: scope, dragging, error, draft, then useBoardTasks' +// own, groupTasks, archive, projects. +function dialog(roster, { scope, own = [], archive = [], projectList = projects, actions = {} } = {}) { + states.length = 0 + states.push(scope, null, '', '', own, [], archive, projectList) + return expand(KanbanDialog({ roster, actions: { closeBoard: () => {}, ...actions } })) +} +const cardsOf = tree => all(tree).filter(node => typeof node.props?.['data-task'] === 'string') +const projectRows = tree => all(tree).filter(node => node.props?.['data-kind'] === 'project') + +test('#1701: the project board splits in flight, waiting and delivered without counting a task twice', () => { + const now = 1_000 * HOURS + const list = [ + task('task-open', 'open'), + task('task-claimed', 'claimed'), + task('task-working', 'working'), + task('task-waiting', 'blocked', { waitingFor: 'input', inputRequest: { id: 'q1', question: 'Which one?' } }), + task('task-result', 'result-ready'), + task('task-shipped', 'accepted', { progress: 100, updatedAt: now - 23 * HOURS }), + task('task-old', 'accepted', { progress: 100, updatedAt: now - 25 * HOURS }), + ] + const columns = projectColumns(list, now) + // Join, not deepEqual: the module builds its arrays in the test's own vm realm. + const ids = column => columns[column].map(card => card.id).join('|') + assert.equal(ids('inFlight'), 'task-open|task-claimed|task-working') + assert.equal(ids('waiting'), 'task-waiting|task-result') + assert.equal(ids('delivered'), 'task-shipped') + const seen = [...columns.inFlight, ...columns.waiting, ...columns.delivered].map(card => card.id) + assert.equal(new Set(seen).size, seen.length, 'no task is counted twice') + assert.equal(seen.length, 6, 'the only card outside the three columns is the one older than 24h') +}) + +test('#1701: delivered today reads status and a 24h window on updatedAt', () => { + const now = 1_000 * HOURS + const columns = projectColumns([ + task('task-fresh', 'accepted', { progress: 100, updatedAt: now - 1 * HOURS }), + task('task-stale', 'accepted', { progress: 100, updatedAt: now - 30 * HOURS }), + task('task-cancelled', 'cancelled', { updatedAt: now - 1 * HOURS }), + task('task-failed', 'failed', { updatedAt: now - 1 * HOURS }), + task('task-working', 'working', { updatedAt: now - 1 * HOURS }), + ], now) + assert.equal(columns.delivered.map(card => card.id).join('|'), 'task-fresh') + assert.equal(columns.inFlight.some(card => card.id === 'task-working'), true, 'a fresh working card is in flight, not delivered') + assert.equal(columns.waiting.some(card => card.id === 'task-failed'), false, 'a failed card is not waiting on the owner') + const everywhere = [...columns.inFlight, ...columns.waiting, ...columns.delivered].map(card => card.id) + for (const gone of ['task-stale', 'task-cancelled', 'task-failed']) assert.equal(everywhere.includes(gone), false, gone) +}) + +test('#1701: projectScope is the one place that turns a project into its members', () => { + const declarations = source.match(/export function projectScope\s*\(/g) ?? [] + assert.equal(declarations.length, 1, 'exactly one declaration of projectScope') + const start = source.indexOf('export function useBoardTasks') + assert.notEqual(start, -1, 'useBoardTasks is a top-level declaration') + const rest = source.slice(start + 'export function useBoardTasks'.length) + const next = Math.min(...['\nexport function ', '\nfunction ', '\nexport const '].map(marker => { + const at = rest.indexOf(marker) + return at === -1 ? Number.POSITIVE_INFINITY : at + })) + const end = start + 'export function useBoardTasks'.length + next + const declaration = source.indexOf('export function projectScope(') + 'export function '.length + const calls = [...source.matchAll(/projectScope\(/g)].map(match => match.index) + assert.ok(calls.length >= 2, 'the declaration and at least one call exist') + for (const at of calls) { + if (at === declaration) continue + assert.ok(at > start && at < end, `projectScope() is called inside useBoardTasks, not at offset ${at}`) + } + assert.equal((source.match(/TEMPORARY/g) ?? []).length, 0, 'the temporary boundary marker is gone') +}) + +test('#1701: a Bot bound to another project never shows on this board', () => { + const roster = rosterWithProjects() + const own = [ + task('task-rocket', 'working', { ownerBotId: 'a' }), + task('task-garden', 'working', { ownerBotId: 'b' }), + task('task-loose', 'working', { ownerBotId: 'c' }), + task('task-room', 'working', { ownerBotId: 'c', origin: { roomId: 'garden' } }), + ] + const scope = projectScope('project-rocket', roster) + assert.equal([...scope.botIds].sort().join('|'), 'a|s') + assert.equal([...scope.roomIds].join('|'), 'war') + + const rocket = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' }, own }) + assert.equal(cardsOf(rocket).map(node => node.props['data-task']).join('|'), 'task-rocket') + + const garden = dialog(roster, { scope: { kind: 'project', projectId: 'project-garden' }, own }) + assert.equal(cardsOf(garden).map(node => node.props['data-task']).join('|'), 'task-garden|task-room') +}) + +test('#1701: the rail lists one row per active project and keeps the Bot rows as they were', () => { + const roster = rosterWithProjects() + const before = boardRail(roster) + const tree = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' } }) + assert.equal(projectRows(tree).map(node => node.props['data-id']).join('|'), 'project-rocket|project-garden', 'archived projects and empty ones are not rail rows') + assert.match(textOf(tree), /Rocket/) + assert.deepEqual(boardRail(roster), before, 'boardRail itself is untouched') + const bots = all(tree).filter(node => node.props?.['data-kind'] === 'bot').map(node => node.props['data-id']) + assert.equal(bots.join('|'), before.bots.map(row => row.id).join('|'), 'every Bot row is still there') + assert.equal(all(tree).filter(node => node.props?.['data-kind'] === 'group').map(node => node.props['data-id']).join('|'), before.groups.map(row => row.id).join('|')) +}) + +test('#1701: with no bound Project the rail shows no Project row and the per-Bot board still opens', () => { + const roster = unboundRoster() + const tree = dialog(roster, { scope: { kind: 'bot', botId: 'a' }, own: [task('task-open', 'open')], projectList: [] }) + assert.equal(projectRows(tree).length, 0, 'no Project row without a binding') + assert.equal(byClass(tree, 'bt-board-rail').length, 1) + assert.equal(cardsOf(tree).map(node => node.props['data-task']).join('|'), 'task-open', 'the per-Bot board still renders its cards') + assert.equal(byClass(tree, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + + // A project whose members were all unbound is not offered either, and nothing throws. + const empty = dialog(unboundRoster(), { scope: { kind: 'bot', botId: 'a' }, projectList: [{ id: 'project-rocket', name: 'Rocket', status: 'active' }] }) + assert.equal(projectRows(empty).length, 0, 'a project with no member is not a rail row') +}) + +test('#1701: the project board is read-only', () => { + const moved = [] + const roster = rosterWithProjects() + const own = [task('task-open', 'open', { ownerBotId: 'a' }), task('task-working', 'working', { ownerBotId: 'a' })] + const tree = dialog(roster, { + scope: { kind: 'project', projectId: 'project-rocket' }, + own, + actions: { taskUpdate: async payload => { moved.push(payload); return payload } }, + }) + const cards = cardsOf(tree) + assert.equal(cards.length, 2) + for (const card of cards) { + assert.ok(card.props['data-column'], 'the column is still drawn') + assert.notEqual(card.props.draggable, true, 'a project card is not draggable') + assert.equal(all(card).some(node => node.type === 'button' && ['Start', 'Complete'].includes(textOf(node))), false, 'no move buttons on a project card') + } + const columns = byClass(tree, 'bt-board-column') + assert.equal(columns.length, 3) + for (const column of columns) { + assert.equal(typeof column.props.onDrop, 'undefined', 'no drop target on the project board') + assert.equal(typeof column.props.onDragOver, 'undefined', 'no drag-over on the project board') + } +}) + +test('#1701: the project board reads the same task call and filters it itself', () => { + const calls = [] + const roster = rosterWithProjects() + const actions = { tasks: (...args) => { calls.push(args); return Promise.resolve([]) }, projects: () => Promise.resolve({ projects }) } + const { useBoardTasks } = module.exports + effects.length = 0 + states.length = 0 + useBoardTasks(actions, { kind: 'project', projectId: 'project-rocket' }, roster) + assert.equal(effects.length, 1, 'the board loads through one effect') + effects.pop()() + return new Promise(resolve => setImmediate(resolve)).then(() => { + assert.equal(calls.filter(args => args[0]?.botId).length, 0, 'a project board never asks Bot by Bot') + assert.equal(calls.length <= 2, true, 'at most the active read and one archive read') + assert.equal(Object.keys(calls[0]?.[0] ?? {}).length, 0, 'the active read stays the unfiltered list') + }) +}) + +// Two behaviour reds, no new export needed: on the base the board has no project scope at all, so a +// project render falls into the default branch and draws every Bot's card, and the rail has no +// Project row. Both fail for what the board does, not because a function is missing. +test('#1701: a Project board renders no card of a Bot or a group from another Project', () => { + const roster = rosterWithProjects() + const own = [ + task('task-rocket', 'working', { ownerBotId: 'a' }), + task('task-garden', 'working', { ownerBotId: 'b' }), + task('task-room', 'working', { ownerBotId: 'c', origin: { roomId: 'garden' } }), + ] + const tree = dialog(roster, { scope: { kind: 'project', projectId: 'project-rocket' }, own }) + const shown = cardsOf(tree).map(node => node.props['data-task']) + assert.equal(shown.includes('task-garden'), false, `the other Project's Bot card is on this board: ${shown.join('|')}`) + assert.equal(shown.includes('task-room'), false, `the other Project's group card is on this board: ${shown.join('|')}`) + assert.equal(shown.join('|'), 'task-rocket') +}) + +test('#1701: the rail carries the active Project as one project row', () => { + const roster = rosterWithProjects() + const tree = dialog(roster, { + scope: { kind: 'bot', botId: 'a' }, + projectList: [{ id: 'project-rocket', name: 'Rocket', status: 'active' }], + }) + const rows = projectRows(tree) + assert.equal(rows.length, 1, `expected one project row, saw ${rows.length}`) + assert.equal(rows[0]?.props['data-id'], 'project-rocket') +})