From 93ea5b16f44d3621826190b5ea50e6de439a8acf Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:44:03 +0800 Subject: [PATCH] client: an approval card says how long it has waited (#2409) A hold from half an hour ago and one that just arrived drew the same card: the stamp was on the record, but none of the three drawing points read it. The dock card and the held reply's bubble now carry relativeShort() beside the head line, and the shared room's overview row carries the same age beside the text; a plugin view without createdAt draws nothing extra. All three surfaces share one rule: a number of milliseconds, an ISO string, or a stamp that cannot be parsed draws nothing, so junk never reaches the head as "Invalid Date". A read-only window keeps the age while it keeps offering no decision, and the tests pin both. --- changelog.d/2409-approval-age.fixed.md | 1 + core/client.js | 26 +- core/src/client/details-drawer.js | 24 +- core/src/client/flybot-slots.js | 6 +- core/src/client/held-reply.js | 6 +- core/src/host/hooks.js | 2 + core/test/approval-age.test.js | 226 ++ docs/subsystems/core-client.md | 6 +- docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 6 +- patches/ds-bot/0008-client-bundle.patch | 2766 +++++++++-------- patches/ds-bot/0012-room-membership.patch | 26 +- .../ds-bot/files/src/client/flybot-slots.js | 6 +- patches/ds-bot/files/src/client/held-reply.js | 6 +- patches/ds-bot/files/src/host/hooks.js | 2 + .../ds-bot/files/test/approval-age.test.js | 226 ++ 16 files changed, 1922 insertions(+), 1415 deletions(-) create mode 100644 changelog.d/2409-approval-age.fixed.md create mode 100644 core/test/approval-age.test.js create mode 100644 patches/ds-bot/files/test/approval-age.test.js diff --git a/changelog.d/2409-approval-age.fixed.md b/changelog.d/2409-approval-age.fixed.md new file mode 100644 index 000000000..ea8345e84 --- /dev/null +++ b/changelog.d/2409-approval-age.fixed.md @@ -0,0 +1 @@ +An approval card now says how long its request has waited. The dock card and the held reply's bubble read `Waiting for approval · 12 min`, and a shared room's overview rows carry the same age beside the request's text; the four existing strings in `text.js` `relativeShort()` do the wording, so a wait under a minute reads `just now` and one that crossed midnight falls back to a date. A card whose record carries no `createdAt` draws nothing extra ([#2409](https://github.com/ThinkFlowLab/FlyBot/issues/2409)). diff --git a/core/client.js b/core/client.js index b160f7c6b..76c11b51c 100644 --- a/core/client.js +++ b/core/client.js @@ -4642,10 +4642,12 @@ function FlyBotApprovals({ useRoster, actions } = {}) { if (custom) return (0, import_react6.createElement)("div", { key: approval.id }, (0, import_react6.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2, readOnly: locked === true } })); const failed = approval.status === "failed"; const waiting = isApprovalWaiting(approval.status); + const at = typeof approval.createdAt === "number" ? approval.createdAt : Date.parse(approval.createdAt); + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; return (0, import_react6.createElement)( "div", { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, - (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval")), + (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval"), waiting ? age : null), (0, import_react6.createElement)( "div", { className: "bt-flybot-approval-origin" }, @@ -6870,10 +6872,12 @@ function HeldReplyActions({ held, actions, roster }) { error ? (0, import_react10.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null ); } + const at = typeof held.createdAt === "number" ? held.createdAt : Date.parse(held.createdAt); + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; const plain = (0, import_react10.createElement)( "div", { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, - (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), + (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), locked ? readOnlyLine() : (0, import_react10.createElement)( "div", @@ -19887,13 +19891,17 @@ function SharedRoomApprovals({ room: room2, roster }) { "div", { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react44.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), - items.map((item) => (0, import_react44.createElement)( - "div", - { key: item.id, className: "bt-set-row" }, - (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), - locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), - locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) - )), + items.map((item) => { + const at = typeof item.createdAt === "number" ? item.createdAt : Date.parse(item.createdAt); + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; + return (0, import_react44.createElement)( + "div", + { key: item.id, className: "bt-set-row" }, + (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply"), age), + locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), + locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + ); + }), error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert" }, error) : null ); } diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 9748daa01..48d466879 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -1,6 +1,6 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { isMainOf } from './sources.js' -import { copyNames, currentPart, transcriptOf, usageOf } from './text.js' +import { copyNames, currentPart, relativeShort, transcriptOf, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' import { GlobeIcon, Favicon } from './browser-pane.js' @@ -142,14 +142,20 @@ function SharedRoomApprovals({ room, roster }) { } return h('div', { key: 'approvals', className: 'bt-shared-wait', 'data-flybot': 'room-pending-approvals' }, h('div', { className: 'bt-section-title' }, t('Waiting for your approval'), h('span', { className: 'bt-chip' }, t('{count} to approve', { count: items.length }))), - items.map(item => h('div', { key: item.id, className: 'bt-set-row' }, - h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply')), - locked - ? null - : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), - locked - ? null - : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))), + items.map((item) => { + // #2409: the plugin's view carries the wait's `createdAt` as an ISO string or not at all + // (`plugins/social/src/social.js` approvalView); anything unparseable draws nothing. + const at = typeof item.createdAt === 'number' ? item.createdAt : Date.parse(item.createdAt) + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null + return h('div', { key: item.id, className: 'bt-set-row' }, + h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply'), age), + locked + ? null + : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), + locked + ? null + : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject'))) + }), error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null) } diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 5673c4744..7bc94ceea 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -47,6 +47,7 @@ import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-cla import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { lockedWindow } from './schedules.js' +import { relativeShort } from './text.js' import { isMainOf } from './sources.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -331,8 +332,11 @@ export function FlyBotApprovals({ useRoster, actions } = {}) { if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call, readOnly: locked === true } })) const failed = approval.status === 'failed' const waiting = isApprovalWaiting(approval.status) + // #2409: one rule for all three surfaces — a number of ms, an ISO string, or nothing drawable. + const at = typeof approval.createdAt === 'number' ? approval.createdAt : Date.parse(approval.createdAt) + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null return h('div', { key: approval.id, className: 'bt-flybot-approval', title: approval.reason ?? undefined }, - h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval')), + h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval'), waiting ? age : null), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: approval.senderName ?? t('A Bot'), to: recipientName(approval, roster) }), approval.request.hop > 0 ? ` · ${t('delegated')}` : ''), diff --git a/core/src/client/held-reply.js b/core/src/client/held-reply.js index 98400170f..ad0a85068 100644 --- a/core/src/client/held-reply.js +++ b/core/src/client/held-reply.js @@ -11,6 +11,7 @@ import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { lockedWindow } from './schedules.js' import { hostText, t } from './i18n.js' +import { relativeShort } from './text.js' const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload) @@ -61,8 +62,11 @@ export function HeldReplyActions({ held, actions, roster }) { failed && canRetry(held) ? (locked ? readOnlyLine() : h('button', { type: 'button', className: 'bt-held-retry', disabled: busy, onClick: () => decide('retry') }, t('Retry'))) : null, error ? h('span', { className: 'bt-flybot-approval-error', role: 'alert' }, error) : null) } + // #2409: same rule as the dock and the room card; an absent or unparseable stamp draws nothing. + const at = typeof held.createdAt === 'number' ? held.createdAt : Date.parse(held.createdAt) + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null const plain = h('div', { className: 'bt-held-card', role: 'region', 'aria-label': t('Waiting for your approval') }, - h('div', { className: 'bt-held-head' }, t('Waiting for your approval')), + h('div', { className: 'bt-held-head' }, t('Waiting for your approval'), age), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: held.from ?? t('A Bot'), to: held.toName })), locked ? readOnlyLine() : h('div', { className: 'bt-flybot-approval-actions' }, h('button', { type: 'button', disabled: busy, onClick: () => decide('reject') }, t('Reject')), diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index f3987dffb..ee1e51044 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -561,6 +561,8 @@ export function install(rt) { .map(a => ({ id: a.id, by: a.by, + // #2409: the bubble draws how long the hold has waited, from the record's own stamp. + createdAt: a.createdAt, status: SENT.has(a.status) || isApprovalWaiting(a.status) || a.status === 'failed' ? a.status : 'rejected', sessionId: a.request.meta.replyIn.sessionId, turn: a.request.meta.replyIn.turn, diff --git a/core/test/approval-age.test.js b/core/test/approval-age.test.js new file mode 100644 index 000000000..9c889f8ea --- /dev/null +++ b/core/test/approval-age.test.js @@ -0,0 +1,226 @@ +// #2409: an approval card never said how long it had been waiting, so a hold from half an hour ago +// and one that had just arrived looked identical. The host has written `createdAt` on every hold +// since the start (`core/src/host/hooks.js` hold), and the room's card receives the plugin's +// `createdAt` (an ISO string or absent), but none of the three drawing points read it. They now do, +// through `relativeShort()` and the four strings already in `i18n.js`: `Waiting for approval · 12 min`. +// React is replaced by the tiny renderer the other client tests use (no jsdom here). +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +// The drawer keeps a `useRef` object and memoises values, so these three answer the way React does. +reactStub.useRef = (value) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } + return inst.hooks[i] +} +reactStub.useMemo = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn() } + return inst.hooks[i].value +} +reactStub.useCallback = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn } + return inst.hooks[i].value +} +for (const name of ['useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +globalThis.__hostCall = () => Promise.resolve([]) +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + if (specifier === 'react-dom') return { url: 'data:text/javascript,export const createPortal = node => node', shortCircuit: true } + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return { url: 'data:text/javascript,export const MarkdownText = node => node;export const FishLogo = node => node', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +function render(Component, inst) { + live.inst = inst + live.idx = 0 + inst.effects = [] + const out = Component(inst.props ?? {}) + inst.rendered = true + return out +} +// The drawer returns elements; React draws the function components inside them, so they are +// expanded here the same way the room-approvals test draws it. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function' && !node.type.prototype?.render) return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.props?.children ?? node.children) +} + +// 12 minutes is the issue's own example: `relativeShort` floors it to `12 min`. +const TWELVE = 12 * 60 * 1000 +const twelveAgo = () => Date.now() - TWELVE + +const host = { id: 'b1', name: 'Helper', ownerId: 'm1', sessionId: 's1' } +const writable = { self: { memberId: 'm1' }, bots: [host], byId: { b1: host }, rooms: [], roomsById: {}, readOnly: '', exchangeTurns: {}, prefs: {}, mainBotIds: [], connectors: [], memory: false, questions: {} } +const approval = (extra = {}) => ({ + id: 'a1', status: 'pending', by: 'tree', senderName: 'Helper', targetName: '@chief.uia.home', + request: { from: 'bot-helper', to: { handle: '@chief.uia.home' }, text: 'Which file?', hop: 0 }, + ...extra, +}) +const room = (pendingApprovals) => ({ + id: 'r1', name: 'Team room', origin: 'group', viewerRole: 'invited-owner', pendingCount: pendingApprovals.length, + pendingApprovals, members: [], participants: [], +}) +const map = () => new Map() +const fakeList = { byId: {}, projectionsBySession: {} } +const actions = { + closeOverlay() {}, openSession() {}, openSettings() {}, openBrowser() {}, + updateRoom: () => Promise.resolve(), notify() {}, reportError() {}, +} +const drawer = (pendingApprovals, rosterExtra = {}) => { + const r = room(pendingApprovals) + return { + hooks: {}, effects: [], rendered: false, + props: { + sessionId: 'r1', startTab: 'details', hasStrip: false, roster: { ...writable, ...rosterExtra, roomsById: { r1: r }, rooms: [r] }, actions, + useSessions: (pick) => pick(fakeList), useSessionStatus: (pick) => pick(map()), + useActivity: (pick) => pick(map()), useWork: (pick) => pick(map()), + useBrowserTabs: (pick) => pick(map()), useBrowserPages: (pick) => pick(map()), + }, + } +} + +// The read-only window is the one the host draws without writes: `lockedWindow()` reads this string. +const lockedRoster = { ...writable, readOnly: 'read-only window' } +// A card is found by one of its own class names, so the assertions below cannot drift onto a +// neighbouring surface: exactly one card, and only its buttons are read. +const findAll = (node, keep, out = []) => { + if (Array.isArray(node)) { for (const child of node) findAll(child, keep, out); return out } + if (!node || typeof node !== 'object') return out + if (keep(node)) out.push(node) + findAll(node.props?.children, keep, out) + return out +} +const card = (node, marker) => { + const found = findAll(node, n => typeof n?.props?.className === 'string' && n.props.className.split(' ').includes(marker)) + assert.equal(found.length, 1, `exactly one ${marker} card`) + return found[0] +} +const buttonLabels = (node) => findAll(node, n => n?.type === 'button').map(n => text(n)) +const decisions = (node) => buttonLabels(node).filter(label => ['Approve', 'Reject', 'Retry'].includes(label)) +const countOf = (hay, needle) => hay.split(needle).length - 1 + +// Hook slots of FlyBotApprovals: useSlot x2 = 0-3, claimed = 4-5, pending = 6. +const PENDING = 6 +const dock = (list, roster = writable) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { actions: {}, useRoster: pick => pick(roster) } } + render(FlyBotApprovals, inst) + inst.hooks[PENDING].value = list + return render(FlyBotApprovals, inst) +} +const bubble = (held, roster = writable) => render(HeldReplyActions, { hooks: {}, effects: [], rendered: false, props: { held, actions: {}, roster } }) +const held = (extra = {}) => ({ id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', ...extra }) + +let FlyBotApprovals +let HeldReplyActions +let DetailsDrawer +test.before(async () => { + ;({ FlyBotApprovals } = await import('../src/client/flybot-slots.js')) + ;({ HeldReplyActions } = await import('../src/client/held-reply.js')) + ;({ DetailsDrawer } = await import('../src/client/details-drawer.js')) +}) + +test('#2409: the dock card says how long the hold has waited', () => { + const drawn = dock([approval({ createdAt: twelveAgo() })]) + assert.match(text(drawn), /Waiting for approval · 12 min/, 'the head line carries the age') +}) + +test('#2409: the held-reply bubble says how long the hold has waited', () => { + const drawn = bubble(held({ createdAt: twelveAgo() })) + assert.match(text(drawn), /Waiting for your approval · 12 min/, 'the head line carries the age') +}) + +test('#2409: the room overview card says how long the hold has waited', () => { + const drawn = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?', createdAt: new Date(twelveAgo()).toISOString() }]))) + assert.match(text(drawn), /Which file\? · 12 min/, 'the row carries the age') +}) + +test('#2409: a card without a createdAt draws no age at all', () => { + const openedDock = dock([approval()]) + assert.doesNotMatch(text(openedDock), /\d+ min|just now|Invalid Date|NaN/, 'the dock draws no age when the field is missing') + const openedBubble = bubble(held()) + assert.doesNotMatch(text(openedBubble), /\d+ min|just now/, 'the bubble draws no age when the field is missing') + const openedRoom = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?' }]))) + assert.doesNotMatch(text(openedRoom), /\d+ min|just now/, 'the room row draws no age when the field is missing') +}) + +// #2409 must-fix: a truthiness test drew " · Invalid Date" for any non-empty junk string. The +// three surfaces now share one rule, so these two cases must read exactly like the missing field — +// byte for byte, not merely "no digits", which is what let the unfixed code stay green. +test('#2409: an unparseable createdAt draws exactly what a missing one draws', () => { + const missing = text(dock([approval()])) + for (const junk of ['not-a-date', 'NaN']) { + const drawn = text(dock([approval({ createdAt: junk })])) + assert.doesNotMatch(drawn, /Invalid Date|NaN/, `the dock draws no junk for ${junk}`) + assert.equal(drawn, missing, `the dock is unchanged by ${junk}`) + } + const missingBubble = text(bubble(held())) + for (const junk of ['not-a-date', 'NaN']) { + const drawn = text(bubble(held({ createdAt: junk }))) + assert.doesNotMatch(drawn, /Invalid Date|NaN/, `the bubble draws no junk for ${junk}`) + assert.equal(drawn, missingBubble, `the bubble is unchanged by ${junk}`) + } +}) + +// Product's finding: the read-only path was only covered by items without `createdAt`, so a +// regression that dropped the age in a locked window stayed silence. This pins all three faces: +// exactly one age, and no decision button at all. +test('#2409: a read-only window still says how long the hold has waited', () => { + const docked = card(dock([approval({ createdAt: twelveAgo() })], lockedRoster), 'bt-flybot-approval') + assert.equal(countOf(text(docked), ' · 12 min'), 1, 'the dock head carries exactly one age') + assert.match(text(docked), /Waiting for approval · 12 min/) + assert.deepEqual(decisions(docked), [], 'a read-only dock card offers no decision') + assert.deepEqual(buttonLabels(docked), [], 'a read-only dock card is not pressable') + + const bubbled = card(bubble(held({ createdAt: twelveAgo() }), lockedRoster), 'bt-held-actions') + assert.equal(countOf(text(bubbled), ' · 12 min'), 1, 'the bubble head carries exactly one age') + assert.match(text(bubbled), /Waiting for your approval · 12 min/) + assert.deepEqual(decisions(bubbled), [], 'a read-only bubble offers no decision') + assert.deepEqual(buttonLabels(bubbled), [], 'a read-only bubble is not pressable') + + const opened = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?', createdAt: new Date(twelveAgo()).toISOString() }], { readOnly: 'read-only window' }))) + const roomCard = card(opened, 'bt-shared-wait') + assert.equal(countOf(text(roomCard), ' · 12 min'), 1, 'the room row carries exactly one age') + assert.match(text(roomCard), /Which file\? · 12 min/) + assert.deepEqual(decisions(roomCard), [], 'a read-only room card offers no decision') + assert.deepEqual(buttonLabels(roomCard), [], 'a read-only room card is not pressable') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8c3cfe3d7..158a1d628 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -14,7 +14,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - Browser client: sidebar, chat, panels, themes, characters, Bot browser panel (`core/client.js` generated from `core/src/client/`). The sidebar **+** new-chat picker (`core/src/client/new-chat.js`) takes extra rows from slot `flybot.new-chat.action` (after New group chat; `label` / `hint` go through the same `t()` map) and lists only `listedChatBots` (gone, hidden, and home-level remotes stay out; owner labels match the sidebar). Covered by `core/test/new-chat.test.js` in patch `0003-flybot-api-client`. - The chat's `ask_user` card (`questions.js`) answers where it is drawn: one option answers a single-select card at once, a multi-select card toggles its rows and **Submit** sends the set, and the × dismisses it; the same card waits in the dock above the composer when its call is not on screen. In a window that holds the team read-only the card keeps the question, its rows and the host's sentence (`roster.readOnly`, read through `lockedWindow` from `schedules.js`) and draws none of those controls — no option button, **Submit**, **Skip**, × or own-answer field, and no form to submit. Each of them reached a write the host refuses (`answer-question`, `dismiss-question`), and the refusal was invisible: a pressed answer marked the card answered and the next poll said nothing, while the × hid the card until that poll brought it back. Tests: `core/test/questions-readonly.test.js`. -- A shared room's overview (`details-drawer.js` `SharedRoomApprovals`) draws its **Waiting for your approval** card in a read-only window (`lockedWindow`, `schedules.js`) with no **Approve**/**Reject**: a decision is `social/decide-approval`, a write the host refuses there, so the two buttons could only earn `bot/read-only` after the click. The card keeps its headline, its count and each request's own text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`), the shape the drawer's own Settings view already used. A decision the host still refuses — a writable window, or a room with several members answering `bot/blocked-shared` — writes that reason on the card in one `role="alert"` line (`bt-error`) instead of vanishing, which is what the component's `try`/`finally` with no `catch` did (#2381). Tests: `core/test/room-approvals-readonly.test.js`. +- A shared room's overview (`details-drawer.js` `SharedRoomApprovals`) draws its **Waiting for your approval** card in a read-only window (`lockedWindow`, `schedules.js`) with no **Approve**/**Reject**: a decision is `social/decide-approval`, a write the host refuses there, so the two buttons could only earn `bot/read-only` after the click. The card keeps its headline, its count and each request's own text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`), the shape the drawer's own Settings view already used. Since #2409 each row also carries the wait's own age beside its text (` · 12 min`, `relativeShort()`), parsed from the plugin view's `createdAt` and left off when that field is absent, so two requests of different ages read apart. A decision the host still refuses — a writable window, or a room with several members answering `bot/blocked-shared` — writes that reason on the card in one `role="alert"` line (`bt-error`) instead of vanishing, which is what the component's `try`/`finally` with no `catch` did (#2381). Tests: `core/test/room-approvals-readonly.test.js`, `core/test/approval-age.test.js`. - Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`. - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). @@ -95,7 +95,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). The face-room create prompt (*Start a new room with {code}*, and the hint under it, #1053) is in that same map, so a zh UI reads it in Chinese. So are the social picker's two face-room rows and the screen they open (#1021): **Join by number or link** (用号码或链接加入) and **Start a face-to-face room** (面对面建群), the start row's hint, and its own about line, all four used through `t()` — so both rows and both dialog titles read Chinese, and the single key #892 wrote for one combined row (*Join or create by number or link*) is gone. -The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send `canRetry()` allows, #968). Approve / Reject is drawn only for a status `isApprovalWaiting()` calls waiting (`pending`, `pending_approval`), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry only while `canRetry()` allows it (#968). Since #2310 a read-only window (`lockedWindow`, `schedules.js`) draws none of the three: the card keeps its two lines, the recipient and the text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`) in their place, because `decide` is not in the host's read path and the buttons could only earn `bot/read-only` after the click. The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). +The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send `canRetry()` allows, #968). Approve / Reject is drawn only for a status `isApprovalWaiting()` calls waiting (`pending`, `pending_approval`), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry only while `canRetry()` allows it (#968). Since #2310 a read-only window (`lockedWindow`, `schedules.js`) draws none of the three: the card keeps its two lines, the recipient and the text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`) in their place, because `decide` is not in the host's read path and the buttons could only earn `bot/read-only` after the click. The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). Since #2409 a waiting card's head line carries the age of the wait beside it (`Waiting for approval · 12 min`, `relativeShort()`), while a failed `Not sent` card draws none. The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). ### Face-to-face room lead @@ -260,7 +260,7 @@ A group or face room shows only its own work (#553, owner decision 2026-10-20 23 - FlyBot `restrict()`s DSH's `ask_user_question` off Bot sessions (`hideBlockingQuestion`). DSH then appends a developer `tool-removal` context row, and `@deepseek-ai/dsh-client-ui-chat` renders it as "Tool removed: {name}". `ContextCell` shadows `conversation.chat.node` `context` and returns null for those notices (`HIDDEN_REMOVED_TOOLS`). A tool a person turns on, and an unrelated removal, still show; `tool-call` cells are unchanged. Empty shadowed nodes collapse via the existing `:empty` flow-item rule. Covered by `core/test/tool-cells.test.js`. - The product's own tools are the same kind of row (#1101). DSH appends a developer message when a session's request header gains or loses a tool. `ContextCell` does not print `Tool added` / `Tool removed` for the team-tool names (`TEAM_TOOLS` in `src/host/constants.js`; the client keeps the same list, so a new team tool is added there in the same pull request — `archive_bot` (#638) is the newest) or for `track_links`, the notice the other home of that report showed on a plain math turn. A call the Bot actually makes stays a tool cell. `web_search` and `bash` still show. Covered by `core/test/hidden-tool-removal.test.js`. -- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send that `canRetry()` allows; a terminal failure draws no button, #968); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the person who gets the reply (#418, #498): the host's `toOwner` (from the hold's `recipientName`, stored, so the same live and after a reload), else the owner of the friend's chat (`friendBotOwner`), so the card reads `Chief → SelfB1233`. The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. In a read-only window the same lock (`lockedWindow`) replaces that bubble's Approve / Reject, and a failed send's Retry, with the host's own sentence on one line, while the bubble itself is drawn as before (#2310, `core/test/approvals-readonly.test.js`). +- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send that `canRetry()` allows; a terminal failure draws no button, #968); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the person who gets the reply (#418, #498): the host's `toOwner` (from the hold's `recipientName`, stored, so the same live and after a reload), else the owner of the friend's chat (`friendBotOwner`), so the card reads `Chief → SelfB1233`. The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. In a read-only window the same lock (`lockedWindow`) replaces that bubble's Approve / Reject, and a failed send's Retry, with the host's own sentence on one line, while the bubble itself is drawn as before (#2310, `core/test/approvals-readonly.test.js`). Since #2409 the waiting card's head line also carries how long the reply has waited (`Waiting for your approval · 12 min`), from the `createdAt` the host now keeps on `heldReplies`. - The held-send overlay (`FlyBotApprovals`, `flybot-slots.js`) runs every hook before its `if (pending.length === 0) return null`: a hook after that return made React throw on the first held message and no card ever drew (#254). `test/overlay-approvals.test.js` renders it with a stand-in that enforces React's hook-count rule. ### Connectors page and the Composio row (#311) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 853a77305..254dd1e28 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -417,7 +417,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Remote chats and mentions -- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` answers from the flags (`retry: false`, `noRetry: true`) or, for a record written before them, from the stored `error`/`code` naming a terminal reason (`no such remote bot`, `is not shared with your Bots`, `is no longer shared with you`, `has not granted chat.public`, `bot_not_shared`, `bot_not_found`; the bare code `capability_not_granted` is not one, #988), so `heldOf()` maps a hold's `retry: false` through it and `approvals.list` marks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). The seam those two spellings cross is covered end to end by `core/test/held-reply-seam.test.js` (#986): a real hold whose dispatch fails `noRetry` lands in the `state` view as `retry: false` and the card drawn from that same object offers no Retry, a retryable failure keeps the button, and both suppressions (a friend's chat, a viewer who is not the owner) are read through the same view. Still not team mode. +- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`, and since #2409 the record's own `createdAt`, so the bubble can draw how long it has waited; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` answers from the flags (`retry: false`, `noRetry: true`) or, for a record written before them, from the stored `error`/`code` naming a terminal reason (`no such remote bot`, `is not shared with your Bots`, `is no longer shared with you`, `has not granted chat.public`, `bot_not_shared`, `bot_not_found`; the bare code `capability_not_granted` is not one, #988), so `heldOf()` maps a hold's `retry: false` through it and `approvals.list` marks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). The seam those two spellings cross is covered end to end by `core/test/held-reply-seam.test.js` (#986): a real hold whose dispatch fails `noRetry` lands in the `state` view as `retry: false` and the card drawn from that same object offers no Retry, a retryable failure keeps the button, and both suppressions (a friend's chat, a viewer who is not the owner) are read through the same view. Still not team mode. - Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too. - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 16c849878..391819479 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -34,9 +34,9 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. Since #2409 the chat card's `heldOf()` map also carries the record's own `createdAt`, so `HeldReplyActions` can say how long the reply has waited. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. Since #1912 `cleanLabel` bounds its text through `cut` in `text.js`, so a cleaned tool name or remote handle never ends on half of a surrogate pair. | -| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems({ bot, readOnly })`, `nodeActions`); `readOnly` is true only in a window that holds the team read-only, the rule `workflows.js` applies to `roster.readOnly`, so a structure entry hides a write the host would refuse there (#2238). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws **Retry** only while `src/shared/held-retry.js` `canRetry(approval)` allows it — a terminal failure asks one shared predicate instead of `status === 'failed'` (#968) — and it publishes `globalThis.__flybot__.canRetry` so a classic-script plugin's own dock asks the same rule rather than keeping a second copy. | +| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems({ bot, readOnly })`, `nodeActions`); `readOnly` is true only in a window that holds the team read-only, the rule `workflows.js` applies to `roster.readOnly`, so a structure entry hides a write the host would refuse there (#2238). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Since #2409 a waiting card's head line carries how long the hold has waited beside it (`Waiting for approval · 12 min`, `relativeShort()` from `client/text.js`); a failed `Not sent` card and a record without `createdAt` draw no age. Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws **Retry** only while `src/shared/held-retry.js` `canRetry(approval)` allows it — a terminal failure asks one shared predicate instead of `status === 'failed'` (#968) — and it publishes `globalThis.__flybot__.canRetry` so a classic-script plugin's own dock asks the same rule rather than keeping a second copy. | | `core/src/client/flybot-registry.js` | Registry. A friend's Bot row in the @ menu carries `remoteMentionLabel` — the one name its bubble and the sidebar preview wear — so the menu inserts the name the user reads, while the row keeps the handle as its value and the send still routes by handle (#967). | | `core/types/flybot.d.ts` | Types (`BotView.hidden`, `bot.update`, …). | @@ -125,4 +125,4 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots - `flybot.bot.send` refuses text over 20,000 characters (`MAX_SEND_TEXT`, `src/host/hooks.js`), and since #1895 that bound counts code points through `codePoints` in `src/host/text.js`, so an emoji is one character and the refusal counts what a reader sees. - A remote sender's line that reaches a local Bot's own chat through `flybot.bot.send` stamps that Bot's `friendLineAt` in the roster (its row turns unread in a window where the chat is not open) and marks the sender's first line `remote.firstContact` (#420, `src/host/delivery.js`). A refused send stamps nothing. Covered by `core/test/flybot.test.js`. - The approval card queue (`FlyBotApprovals`) runs all its hooks on every render, before its empty-queue early return: a hook after it makes React throw when the first hold arrives and no card ever draws (#254; `core/test/overlay-approvals.test.js`). -- A card's decisions are writes. In a window that holds the team read-only (`lockedWindow` from `client/schedules.js`) `FlyBotApprovals` draws no `Retry` / `Reject` / `Approve` and puts the host's own sentence in their place on one `role="status"` line (`bt-hint bt-read-only`). The count is per status and never three on one card — a pending card draws 2 (Reject, Approve) and a failed-but-retryable one 1 (Retry) in a writable window, and the two statuses are exclusive. The held-reply bubble (`HeldReplyActions`, roster passed from `cells.js`) follows the same rule in both of its shapes, including the settled `bt-held-note` Retry next to "Not sent: delivery failed". `flybot/decide` is not one of the host's read endpoints, so the click could only earn `bot/read-only` (#2310; `core/test/approvals-readonly.test.js`). +- A card's decisions are writes. In a window that holds the team read-only (`lockedWindow` from `client/schedules.js`) `FlyBotApprovals` draws no `Retry` / `Reject` / `Approve` and puts the host's own sentence in their place on one `role="status"` line (`bt-hint bt-read-only`). The count is per status and never three on one card — a pending card draws 2 (Reject, Approve) and a failed-but-retryable one 1 (Retry) in a writable window, and the two statuses are exclusive. Since #2409 that waiting bubble's head line also carries the age of the wait (`Waiting for your approval · 12 min`). The held-reply bubble (`HeldReplyActions`, roster passed from `cells.js`) follows the same rule in both of its shapes, including the settled `bt-held-note` Retry next to "Not sent: delivery failed". `flybot/decide` is not one of the host's read endpoints, so the click could only earn `bot/read-only` (#2310; `core/test/approvals-readonly.test.js`). diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 8abebd15b..43d4c116e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2191,7 +2191,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,1039 +1624,3124 @@ function usageOf(list, ids) { +@@ -1624,1039 +1624,3126 @@ 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 }); } @@ -5059,10 +5059,12 @@ diff --git a/client.js b/client.js + if (custom) return (0, import_react6.createElement)("div", { key: approval.id }, (0, import_react6.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2, readOnly: locked === true } })); + const failed = approval.status === "failed"; + const waiting = isApprovalWaiting(approval.status); ++ const at = typeof approval.createdAt === "number" ? approval.createdAt : Date.parse(approval.createdAt); ++ const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; + return (0, import_react6.createElement)( + "div", + { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, -+ (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval")), ++ (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval"), waiting ? age : null), + (0, import_react6.createElement)( + "div", + { className: "bt-flybot-approval-origin" }, @@ -6324,7 +6326,7 @@ diff --git a/client.js b/client.js @keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} @keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} @keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -@@ -2676,2104 +2676,4373 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2104 +2676,4375 @@ 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} } @@ -7400,10 +7402,12 @@ diff --git a/client.js b/client.js + ); } - return names; ++ const at = typeof held.createdAt === "number" ? held.createdAt : Date.parse(held.createdAt); ++ const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; + const plain = (0, import_react10.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, -+ (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval")), ++ (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), + (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + locked ? readOnlyLine() : (0, import_react10.createElement)( + "div", @@ -7609,7 +7613,8 @@ diff --git a/client.js b/client.js + window.removeEventListener("keydown", onKey, true); }; - }, [enabled]); --} ++ }, [open]); + } -var FIDGETS = ["hop", "sway", "stretch", "look"]; -function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot); @@ -7635,38 +7640,6 @@ diff --git a/client.js b/client.js - onAnimationEnd: poked ? (event) => { - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); --} --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -- ); --} --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" }) -- ); -+ }, [open]); - } --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 MessageTools({ text: text2, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react11.useState)(null); + const [place, setPlace] = (0, import_react11.useState)(null); @@ -7679,74 +7652,15 @@ diff --git a/client.js b/client.js + if (panel === kind) { + close(); + return; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); --} --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } -- 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)); -- }) ++ } + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); + setPanel(kind); -+ }; + }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); + const copy2 = () => { + void navigator.clipboard?.writeText(text2).then(() => { + setCopied(true); @@ -7783,24 +7697,8 @@ diff --git a/client.js b/client.js + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null - ); - } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) ++ ); ++} +var PEEK_MAX = 160; +var CUT = "…"; +var MARKUP = [/^\s{0,3}(?:`{3,}|~{3,})/, /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/]; @@ -7835,10 +7733,8 @@ diff --git a/client.js b/client.js + peek ? (0, import_react11.createElement)("span", { className: "bt-think-peek" }, peek) : null + ), + (0, import_react11.createElement)("div", { className: "bt-think-text" }, text2) - ); - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); ++ ); ++} +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -7848,36 +7744,18 @@ diff --git a/client.js b/client.js + if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); - } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); ++} +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; - } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); ++} +function friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text2) => /^Not sent:/.test(text2.trim()))) return void 0; + return (0, import_react11.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react11.createElement)(BotMark, { bot, size: 22 })); - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) ++} +function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -7915,27 +7793,8 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react11.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react11.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles - ); - } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); --}; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) ++ ); ++} +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -7945,33 +7804,8 @@ diff --git a/client.js b/client.js + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react11.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions, roster }) : null - ); - } --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) ++ ); ++} +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + if (!(texts2 ?? []).some((text2) => String(text2 ?? "").trim() !== "")) return null; + const lead = (0, import_react11.createElement)("span", { className: "bt-lead", title: name }, (0, import_react11.createElement)(PersonMark, { name })); @@ -7980,159 +7814,53 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react11.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react11.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); ++ ); ++} ++function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { ++ if (whole && bots.length === 1) { ++ return (0, import_react11.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react11.createElement)( ++ "button", ++ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, ++ (0, import_react11.createElement)("span", null, prefix), ++ (0, import_react11.createElement)(BotMark, { bot: bots[0], size: 16 }), ++ (0, import_react11.createElement)("span", { className: "bt-event-name" }, bots[0].name) ++ ) ++ ); ++ } ++ const parts = []; ++ parts.push((0, import_react11.createElement)("span", { key: "p" }, prefix)); ++ bots.forEach((bot, index) => { ++ if (index > 0) parts.push((0, import_react11.createElement)("span", { key: `j${index}` }, joiner)); ++ parts.push((0, import_react11.createElement)( ++ "button", ++ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, ++ (0, import_react11.createElement)(BotMark, { bot, size: 16 }), ++ bot.name ++ )); ++ }); ++ return (0, import_react11.createElement)("div", { className: "bt-event" }, parts); ++} ++var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); ++var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); ++function isHeldLine(room2, text2, bot) { ++ if (room2?.origin !== "face-to-face") return false; ++ const line2 = heldForm(text2); ++ if (!line2) return false; ++ return (room2.pendingApprovals ?? []).some((item) => { ++ if (item?.kind !== "face_room_message") return false; ++ 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)); ++ }); } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); -- } -- }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); --} --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); -- } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -+function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { -+ return (0, import_react11.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react11.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -+ (0, import_react11.createElement)("span", null, prefix), -+ (0, import_react11.createElement)(BotMark, { bot: bots[0], size: 16 }), -+ (0, import_react11.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) - ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); - } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -+ const parts = []; -+ parts.push((0, import_react11.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { -+ if (index > 0) parts.push((0, import_react11.createElement)("span", { key: `j${index}` }, joiner)); -+ parts.push((0, import_react11.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -+ (0, import_react11.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); -+ return (0, import_react11.createElement)("div", { className: "bt-event" }, parts); -+} -+var heldForm = (text2) => String(text2 ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -+var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); -+function isHeldLine(room2, text2, bot) { -+ if (room2?.origin !== "face-to-face") return false; -+ const line2 = heldForm(text2); -+ if (!line2) return false; -+ return (room2.pendingApprovals ?? []).some((item) => { -+ if (item?.kind !== "face_room_message") return false; -+ 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 StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text: text2 } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -8149,60 +7877,18 @@ diff --git a/client.js b/client.js + const held = heldLineState(room2, { seq, botId: bot.id, text: text2 }); + if (held === "rejected") { + return (0, import_react11.createElement)( - "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) ++ "div", + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react11.createElement)(BotMark, { bot, size: 16 }), + (0, import_react11.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) - ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); - } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot ++ ); ++ } + if (held === "held" || isHeldLine(room2, text2, bot)) return null; + const lead = (0, import_react11.createElement)( + "button", + { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + (0, import_react11.createElement)(BotMark, { bot, size: 22 }) - ); --} --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( ++ ); + const name = (0, import_react11.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -8214,8 +7900,10 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg" }, + name, + (0, import_react11.createElement)(Bubbles, { texts: [text2], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+ ); -+} + ); + } +-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 remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -8258,14 +7946,7 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react11.createElement)( - "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) ++ "div", + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react11.createElement)( @@ -8276,25 +7957,12 @@ diff --git a/client.js b/client.js + ), + String(words ?? "").trim() === "" ? null : (0, import_react11.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react11.createElement)("span", { className: "bt-lead", title: person }, (0, import_react11.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react11.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } -- return (0, import_react4.createElement)( ++ ); ++ } + const mark = (0, import_react11.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react11.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react11.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react11.createElement)( - "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) ++ "div", + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react11.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react11.createElement)( @@ -8305,21 +7973,10 @@ diff --git a/client.js b/client.js + ), + String(words ?? "").trim() === "" ? null : (0, import_react11.createElement)(Bubbles, { texts: [words], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react11.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); ++ ); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; +-function MainStar() { +- return (0, import_react2.createElement)( +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -8328,13 +7985,24 @@ diff --git a/client.js b/client.js +} +function GlobeMark() { + return (0, import_react11.createElement)( -+ "svg", + "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" }) + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react11.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react11.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ); -+} -+function RemoteFiles({ files, plugin, botId }) { + ); + } +-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 RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react11.useState)(""); + const [error, setError] = (0, import_react11.useState)(""); + const download2 = async (file) => { @@ -8353,9 +8021,13 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); } -- pending.answer({ answers: next }); - }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; ++ }; + return (0, import_react11.createElement)( + "div", + { className: "bt-file-chips" }, @@ -8367,34 +8039,36 @@ diff --git a/client.js b/client.js + error ? (0, import_react11.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -- }; -- return (0, import_react4.createElement)( +-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 GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react11.createElement)( - "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) ++ "div", + { className: "bt-event" }, + (0, import_react11.createElement)("span", null, prefix), + room2 ? (0, import_react11.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react11.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react11.createElement)("span", null, name) : null ); } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +-function 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 MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -8409,79 +8083,40 @@ diff --git a/client.js b/client.js + (0, import_react11.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react11.createElement)(MemoryIcon), label) + ); } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); +-function 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 PartLine({ time }) { + return (0, import_react11.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react11.createElement)("span", null, dayLabel(time)) : null); - } -- --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- if (scope === "all") return t("All Bots"); -- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); -- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++} +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -+ } + } +- 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}`); + return null; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); ++}; +function EarlierParts({ sessionId, roster, actions, useUi }) { + const [view, setView] = (0, import_react11.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react11.useRef)(null); @@ -8536,7 +8171,7 @@ diff --git a/client.js b/client.js + last.msgKeys.push(keyOf(item)); + } else rows.push({ kind: "bot", texts: [item.text], msgKeys: [keyOf(item)] }); + continue; - } ++ } + rows.push(item); + } + return (0, import_react11.createElement)( @@ -8573,11 +8208,19 @@ diff --git a/client.js b/client.js + } + default: + return null; -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) + }), + (0, import_react11.createElement)(PartLine, { time: view.startedAt }) -+ ); -+} + ); + } +- +-// 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 TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -8603,7 +8246,7 @@ diff --git a/client.js b/client.js + const unreadOpening = useTurnClock((state) => { + const first = openingTurn(state[sessionId], order, hiddenTurns); + return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; - }); ++ }); + const body = triggerBody(); + return unreadOpening ? (0, import_react11.createElement)(import_react11.Fragment, null, (0, import_react11.createElement)("div", { className: "bt-open-new" }, (0, import_react11.createElement)(NewDivider), (0, import_react11.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; + function triggerBody() { @@ -8657,38 +8300,10 @@ diff --git a/client.js b/client.js + if (source.kind === "user-question-reply") return null; + return (0, import_react11.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -- ); ++} +function NewDivider() { + return (0, import_react11.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react11.createElement)("span", null, t("New"))); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null -- ); ++} +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react11.useEffect)(() => { @@ -8706,87 +8321,12 @@ diff --git a/client.js b/client.js + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react11.createElement)(NewDivider) : null; ++} ++function Nothing() { ++ return null; } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) -- ); -+function Nothing() { -+ return null; - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); +-function 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" })); +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([ @@ -8843,8 +8383,14 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react11.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } --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 PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +- ); +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -8856,32 +8402,23 @@ diff --git a/client.js b/client.js + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +function nextTurnStart(turns, turn) { + let next; + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } -- return null; ++ } + return next === void 0 ? void 0 : turns[next].first; } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +- ); +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -8891,37 +8428,11 @@ diff --git a/client.js b/client.js + 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")) -- ) +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) - ); +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { @@ -8932,34 +8443,11 @@ diff --git a/client.js b/client.js + } + 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 +-function ShareIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) +var 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; @@ -8985,55 +8473,113 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react11.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 CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +function Shimmer({ children }) { + return (0, import_react11.createElement)("span", { className: "bt-shimmer" }, children); -+} + } +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +- ); +var 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 CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) +- ); +-} +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +- ); +-} +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +- }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); +-} +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +var TOOL_PHRASES = [ + [/web_?search|search_?web/i, "Searching online"], + [/fetch|browse|browser|web/i, "Reading a page"], @@ -9059,11 +8605,75 @@ diff --git a/client.js b/client.js + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } + } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -+ } + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); +- } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + case "create_bot": + return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; + case "update_bot": { @@ -9086,26 +8696,106 @@ 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 (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); + } +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +- } +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) +- ); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); } --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 QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; +- } +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; -+} + } +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react11.useEffect)(() => { @@ -9129,10 +8819,7 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); - } -- }); -- }; -- return (0, import_react5.createElement)( ++ } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; + void tick(); @@ -9153,17 +8840,8 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById[sessionId]; + return (0, import_react11.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")) -- ) +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -9246,29 +8924,12 @@ diff --git a/client.js b/client.js + }) ); } --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 QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +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) { @@ -9283,7 +8944,19 @@ diff --git a/client.js b/client.js + }; + visit(root); + return calls; -+} + } +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +- } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react11.useLayoutEffect)(() => { @@ -9292,7 +8965,60 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); -+} + } +- +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- if (scope === "all") return t("All Bots"); +- const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); +- return names.length === 0 ? t("No Bot") : names.join(t(" and ")); +-}; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -9337,7 +9063,8 @@ diff --git a/client.js b/client.js + const name = named[2]; + const room2 = roster.rooms.find((entry) => entry.name === name); + return (0, import_react11.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); -+ } + } +- }); + case "ask_user": { + const question = roster.questions?.[sessionId]; + if (!question || question.callId !== call4.callId) return (0, import_react11.createElement)("span", { className: "bt-q-gone", hidden: true }); @@ -9369,7 +9096,22 @@ diff --git a/client.js b/client.js + default: + return null; + } -+} + } +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +- ); +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; @@ -9378,83 +9120,28 @@ diff --git a/client.js b/client.js + const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); + const calls = hidden ? [] : callTree(root); + const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); -+ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); -+ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; -+ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -+ useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); -+ if (drawn.length === 0) return null; -+ const env = { sessionId, roster, actions, useUi, findBot }; -+ return (0, import_react11.createElement)(import_react11.Fragment, null, drawn.map((call4, index) => (0, import_react11.createElement)(import_react11.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"); ++ const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); ++ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; ++ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); ++ useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); ++ if (drawn.length === 0) return null; ++ const env = { sessionId, roster, actions, useUi, findBot }; ++ return (0, import_react11.createElement)(import_react11.Fragment, null, drawn.map((call4, index) => (0, import_react11.createElement)(import_react11.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); + } +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- ); ++ +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { @@ -9462,56 +9149,65 @@ diff --git a/client.js b/client.js + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } --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); +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); - }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( - "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) - ); ++ +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; +function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { @@ -9519,13 +9215,25 @@ diff --git a/client.js b/client.js + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; } --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(); +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) +- ); + +// src/client/activity-row-host.js +var RUNNING_HOST = "[data-chat-flow] [data-chat-running]"; @@ -9535,7 +9243,9 @@ 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); -+} + } +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); + +// src/client/activity-row.js +var import_react12 = require("react"); @@ -9547,7 +9257,16 @@ diff --git a/client.js b/client.js + if (minutes < 60) return `${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; -+} + } +-function 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") }))); +- } +- return null; +var REMOTE_GRACE_MS = 1500; +function useAwaitingReply(roster, sessionId) { + const at = roster?.remoteAwaiting?.[sessionId]; @@ -9559,7 +9278,17 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; -+} + } +-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 findHost(where) { + if (where !== TAIL_HOST) return findRunningHostIn(document); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -9595,11 +9324,7 @@ diff --git a/client.js b/client.js + const check = () => { + frame = 0; + if (lastTurnOnScreen("assistant-step") > sendTurn.current) setReplied(true); - }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); + }); @@ -9611,7 +9336,39 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; -+} + } +-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 useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react12.useState)(null); + (0, import_react12.useEffect)(() => { @@ -9636,7 +9393,17 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; -+} + } +-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 useRowEntrances(replacing) { + const check = (0, import_react12.useRef)(replacing); + check.current = replacing; @@ -9657,7 +9424,25 @@ diff --git a/client.js b/client.js + observer.disconnect(); + column = next; + if (column) observer.observe(column, { childList: true, subtree: 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 +- ); + if (!column) return; + const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; + let last = -1; @@ -9672,10 +9457,8 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); - } - }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); ++ } ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -9685,7 +9468,16 @@ diff --git a/client.js b/client.js + observer.disconnect(); + }; + }, []); -+} + } +-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)( +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -9781,22 +9573,237 @@ diff --git a/client.js b/client.js + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react12.createElement)( -+ "div", + "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)( + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react12.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react12.createElement)( + "span", + { className: "bt-typing-text" }, + (0, import_react12.createElement)( -+ "span", + "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")) + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react12.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) + ) +- ) : null +- ); +-} +-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 SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +-} +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +-`; +- +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); + ), host); -+} -+ + } +-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 +- ); +- }) +- ); +-} +-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/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; @@ -9822,7 +9829,9 @@ diff --git a/client.js b/client.js + return () => { + escapeLayers.splice(escapeLayers.indexOf(ref), 1); + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; + }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); + }, []); +} +function useDialogFocus(ref) { @@ -10082,12 +10091,6 @@ diff --git a/client.js b/client.js - )); - }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); --} --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) + const rows = groups.slice(0, ROWS_MAX); + const more = groups.length - rows.length; + return (0, import_react14.createElement)( @@ -10108,9 +10111,13 @@ diff --git a/client.js b/client.js + { className: "bt-mw-row bt-mw-line bt-mw-static" }, + (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, moreRooms(more)) + ) : null - ); -- return (0, import_react6.createElement)( -+} ++ ); + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +function useFlowColumn(ref, active, key) { + (0, import_react14.useLayoutEffect)(() => { + const node = ref.current; @@ -10149,15 +10156,7 @@ diff --git a/client.js b/client.js + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); + }; + return (0, import_react14.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ "div", + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react14.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react14.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, @@ -10175,9 +10174,8 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) + ) ); - } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { - return (0, import_react6.createElement)( ++} +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react14.useState)(false); + if (entry.state === "ask") { @@ -10195,9 +10193,14 @@ diff --git a/client.js b/client.js + }; + return (0, import_react14.createElement)( "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +- "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + { className: "bt-mw-member" }, + (0, import_react14.createElement)( + "div", @@ -10207,6 +10210,19 @@ diff --git a/client.js b/client.js + ) ); } +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- ); ++function useMemberWork(room2, useWork) { ++ const work = useWork?.((value) => value) ?? NO_WORK; ++ const entries2 = room2 ? memberWork(room2, work) : []; ++ const now = useNow(entries2.some((entry) => entry.state !== "idle")); ++ return { entries: entries2, now }; + } -function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { - if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; - const self = roster.byId[sessionId]; @@ -10220,14 +10236,6 @@ diff --git a/client.js b/client.js - { className: "bt-event" }, - (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) - ); -+function useMemberWork(room2, useWork) { -+ const work = useWork?.((value) => value) ?? NO_WORK; -+ const entries2 = room2 ? memberWork(room2, work) : []; -+ const now = useNow(entries2.some((entry) => entry.state !== "idle")); -+ return { entries: entries2, now }; - } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +var 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} @@ -10315,9 +10323,8 @@ diff --git a/client.js b/client.js + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); + +// src/client/inbox.js +var import_react15 = require("react"); @@ -10409,7 +10416,10 @@ diff --git a/client.js b/client.js + }; + }, []); + return workflows; -+} + } +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -11520,8 +11530,11 @@ diff --git a/client.js b/client.js - const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; - return { label: phrase ? t(phrase) : null, state: toolState(name) }; + return t("Not started"); -+ } -+} + } + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); + if (column === "done") return t("Complete task"); @@ -11545,7 +11558,7 @@ diff --git a/client.js b/client.js + order.push(id); + } + map.get(id).push(task); - } ++ } + return order.map((id) => ({ id, name: botName(roster, map.get(id)[0]) || id, tasks: map.get(id) })); +} +var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; @@ -11776,10 +11789,7 @@ diff --git a/client.js b/client.js + ) + ) + ); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); ++} +function KanbanRow({ wide, useUi, actions }) { + const open = useUi ? useUi((value) => Boolean(value.board)) : false; + const label = t("Tasks"); @@ -13391,10 +13401,11 @@ diff --git a/client.js b/client.js @keyframes bt-update-pop{from{opacity:0;transform:scale(.96);filter:blur(4px)}} .bt-update-title{font-size:14px;line-height:20px;font-weight:600} .bt-update-text{margin-top:2px;font-size:12.5px;line-height:18px;color:var(--bt-ink-2)} -@@ -5482,1688 +5482,2803 @@ var SIDEBAR_CSS = ` +@@ -5481,1689 +5481,2804 @@ var SIDEBAR_CSS = ` + .bt-agent-sub-in{overflow:hidden;min-height:0;margin-left:44px} .bt-agent-sub-row{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px 5px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer} .bt-agent-sub-row:hover{background:var(--bt-hover);color:var(--bt-ink)} - .bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} +-.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} -.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} -.bt-agent-new{color:var(--bt-ink-3)} @@ -13697,6 +13708,7 @@ diff --git a/client.js b/client.js - actions.clearQuote(quote.token); - const excerpt = quote.text.replace(/\s+/g, " ").trim(); - const line = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt} ++.bt-agent-sub-row[aria-current=page]{background:var(--bt-hover);color:var(--bt-ink);font-weight:500} +.bt-agent-sub-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-agent-sub-time{flex:none;font-size:11px;color:var(--bt-ink-3)} +.bt-agent-new{color:var(--bt-ink-3)} @@ -14101,45 +14113,6 @@ diff --git a/client.js b/client.js + if (id !== "" && (stated.kind === "group" || stated.kind === "bot")) return { kind: stated.kind, id }; } - return rows; --} --function chatRows(roster, list, sessionId) { -- const here = conversationOf(roster, sessionId); -- if (here === null) return []; -- const byId = list?.byId ?? {}; -- const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { -- const id = currentPart(bot); -- return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; -- }); -- const team = /* @__PURE__ */ new Set([ -- ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), -- ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) -- ]); -- const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); -- return [...bots, ...others]; --} --function sessionMention(sessionId, label) { -- let binary = ""; -- for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); -- const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -- return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; --} --function atSources({ roster, sessions, launched, iconFor }) { -- const bots = { -- trigger: "@", -- name: BOTS_SOURCE, -- order: -20, -- showGroupTitle: false, -- async candidates(session, { query }) { -- return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map((row) => ({ -- name: row.bot ? row.bot.name : t("Everyone"), -- ...row.bot?.role ? { description: row.bot.role } : {}, -- ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, -- icon: iconFor(row), -- section: t("Bots"), -- value: row.text -- })); -- }, -- onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 + if (task === void 0) return null; + if (row.source === "group") { + const id = text(task.origin?.roomId); @@ -14163,26 +14136,7 @@ diff --git a/client.js b/client.js + title: text(file.title), + sha256: text(file.sha256), + shortHash: text(file.sha256).slice(0, 12) - }; -- const chats = { -- trigger: "@", -- name: CHATS_SOURCE, -- order: -10, -- showGroupTitle: false, -- async candidates(session) { -- if (!launched(session.sessionId)) return []; -- return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ -- name: row.bot ? row.bot.name : row.label, -- ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, -- icon: row.bot ? iconFor(row) : "session", -- section: row.bot ? t("Bots") : t("Conversations"), -- value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) -- })); -- }, -- onPick({ candidate }) { -- const { sessionId, label } = JSON.parse(candidate.value); -- const mention = sessionMention(sessionId, label); -- return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; ++ }; +}; +var meetingOf = (meeting, status) => { + if (meeting === null || typeof meeting !== "object") return null; @@ -14228,8 +14182,7 @@ diff --git a/client.js b/client.js + hidden: hiddenOf(decisionsTotal, files.length), + empty: files.length === 0, + emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`) - }, -- codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } ++ }, + meetings: { + decided, + pending, @@ -14241,19 +14194,15 @@ diff --git a/client.js b/client.js + empty: decided.length === 0 && pending.length === 0, + emptyText: text(meetings?.reason) || "No meeting of this Project is recorded." + } - }; -- return [bots, chats]; ++ }; } -- --// src/client/speech.js --var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; --var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); --async function speechCatalog(speech) { -- const result = await speech.catalog(); -- if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); -- const catalog = result.value; -- const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); -- return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; +-function chatRows(roster, list, sessionId) { +- const here = conversationOf(roster, sessionId); +- if (here === null) return []; +- const byId = list?.byId ?? {}; +- const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => { +- const id = currentPart(bot); +- return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt }; +function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) { + const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== ""; + const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task])); @@ -14298,7 +14247,37 @@ diff --git a/client.js b/client.js + const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source); + const task = byId.get(id); + return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) }; -+ }); + }); +- const team = /* @__PURE__ */ new Set([ +- ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]), +- ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []]) +- ]); +- const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt })); +- return [...bots, ...others]; +-} +-function sessionMention(sessionId, label) { +- let binary = ""; +- for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte); +- const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +- return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`; +-} +-function atSources({ roster, sessions, launched, iconFor }) { +- const bots = { +- trigger: "@", +- name: BOTS_SOURCE, +- order: -20, +- showGroupTitle: false, +- async candidates(session, { query }) { +- return (mentionRows(roster.getSnapshot(), session.sessionId, query) ?? []).map((row) => ({ +- name: row.bot ? row.bot.name : t("Everyone"), +- ...row.bot?.role ? { description: row.bot.role } : {}, +- ...row.room ? { description: t("{count} members", { count: row.room.members.length }) } : {}, +- icon: iconFor(row), +- section: t("Bots"), +- value: row.text +- })); +- }, +- onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0 + const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); + const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" })); + const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null })); @@ -14323,7 +14302,26 @@ diff --git a/client.js b/client.js + emptyText: "No cards yet.", + record: projectBoardRecord({ decisions, meetings }), + metrics: metricsView(metrics, { tasks }) -+ }; + }; +- const chats = { +- trigger: "@", +- name: CHATS_SOURCE, +- order: -10, +- showGroupTitle: false, +- async candidates(session) { +- if (!launched(session.sessionId)) return []; +- return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({ +- name: row.bot ? row.bot.name : row.label, +- ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {}, +- icon: row.bot ? iconFor(row) : "session", +- section: row.bot ? t("Bots") : t("Conversations"), +- value: JSON.stringify({ sessionId: row.sessionId, label: row.label }) +- })); +- }, +- onPick({ candidate }) { +- const { sessionId, label } = JSON.parse(candidate.value); +- const mention = sessionMention(sessionId, label); +- return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } }; +} +function projectRowNode(row, onOpen) { + const open = row.board === null ? void 0 : () => onOpen?.(row.board); @@ -14341,7 +14339,10 @@ diff --git a/client.js b/client.js + onKeyDown: open === void 0 ? void 0 : (event) => { + if (event.key === "Enter" || event.key === " ") open(); + } -+ }, + }, +- codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) } +- }; +- return [bots, chats]; + (0, import_react21.createElement)("span", { className: "bt-project-column" }, t(row.column)), + (0, import_react21.createElement)( + "span", @@ -14358,7 +14359,17 @@ diff --git a/client.js b/client.js + row.priority === null ? null : (0, import_react21.createElement)("span", { className: "bt-project-priority" }, row.priority), + row.runId === null ? null : (0, import_react21.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`) + ); -+} + } +- +-// src/client/speech.js +-var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; +-var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); +-async function speechCatalog(speech) { +- const result = await speech.catalog(); +- if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed"); +- const catalog = result.value; +- const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId); +- return { catalog, usable: USABLE.has(provider?.preparation?.phase) }; +function ProjectBoardRows({ view, onOpen }) { + if (view.empty) return (0, import_react21.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText)); + return view.groups.map((group) => (0, import_react21.createElement)( @@ -14871,9 +14882,9 @@ diff --git a/client.js b/client.js + target = ""; } + if (target !== "") setAgentId(target); - } ++ } + if (target !== "") onOpen?.(target); - }; ++ }; + if (id === "") return null; + return (0, import_react21.createElement)("button", { + type: "button", @@ -14917,12 +14928,7 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)(ProjectDoneGroup, { view, onOpen }), + (0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) + ); - } --async function transcribeWave(speech, catalog, wave, signal) { -- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); -- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); -- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); -- return String(result.value?.text ?? "").trim(); ++} +function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react21.useState)("bot"); @@ -14943,8 +14949,8 @@ diff --git a/client.js b/client.js + } catch (error) { + setStatus(hostText(error?.message ?? String(error))); + return null; -+ } -+ }; + } + }; + return (0, import_react21.createElement)( + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, @@ -15055,23 +15061,16 @@ diff --git a/client.js b/client.js + (0, import_react21.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")), + (0, import_react21.createElement)( + "div", -+ { className: "bt-project-row-actions" }, -+ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) -+ ) -+ ); - } --function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { -- let spokeAt = 0; -- let quietSince = 0; -- return (level, now) => { -- if (level >= loud) { -- spokeAt ||= now; -- quietSince = 0; -- return "speech"; -- } -- if (!spokeAt) return "waiting"; -- if (level < quiet) quietSince ||= now; -- return quietSince && now - quietSince >= quietMs ? "done" : "speech"; ++ { className: "bt-project-row-actions" }, ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board")) ++ ) ++ ); + } +-async function transcribeWave(speech, catalog, wave, signal) { +- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long"); +- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal); +- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" }); +- return String(result.value?.text ?? "").trim(); +async function readProjectBoard(read, wanted, knownTeam) { + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; @@ -15107,25 +15106,16 @@ diff --git a/client.js b/client.js + metrics: board?.metrics ?? null, + team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, + teamFresh: dossier.fresh - }; ++ }; } -- --// src/client/overlay-hooks.js --var import_react11 = require("react"); --var escapeLayers = []; --var onEscapeKey = (event) => { -- if (event.key === "Escape") escapeLayers.at(-1)?.current(); --}; --function useEscape(onClose) { -- const ref = (0, import_react11.useRef)(onClose); -- ref.current = onClose; -- (0, import_react11.useEffect)(() => { -- if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); -- escapeLayers.push(ref); -- return () => { -- escapeLayers.splice(escapeLayers.indexOf(ref), 1); -- if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -- }; +-function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { +- let spokeAt = 0; +- let quietSince = 0; +- return (level, now) => { +- if (level >= loud) { +- spokeAt ||= now; +- quietSince = 0; +- return "speech"; +var PROJECT_BOARD_POLL_MS = 5e3; +var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project"); +function ProjectBoardOverlay({ actions, call: request } = {}) { @@ -15140,13 +15130,7 @@ diff --git a/client.js b/client.js + if (typeof window !== "undefined") window.location.hash = ""; + setPanel(null); + setState(null); - }, []); --} --function usePaneLeft() { -- const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); -- (0, import_react11.useLayoutEffect)(() => { -- const column = document.querySelector('[class*="_footArea"]')?.parentElement; -- if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); ++ }, []); + (0, import_react21.useEffect)(() => { + if (typeof window === "undefined") return void 0; + const onKey = (event) => { @@ -15163,43 +15147,7 @@ diff --git a/client.js b/client.js + window.addEventListener("hashchange", sync); + sync(); + return () => window.removeEventListener("hashchange", sync); - }, []); -- return paneLeft; --} -- --// src/client/palette.js --var import_react12 = require("react"); --var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); --var PALETTE_EXIT_MS = 160; --function CommandPalette({ start, token, roster, actions, useSessions }) { -- const [query, setQuery] = (0, import_react12.useState)(start ?? ""); -- const [cursor, setCursor] = (0, import_react12.useState)(0); -- const [leaving, setLeaving] = (0, import_react12.useState)(false); -- const wanted = query.trim().toLowerCase(); -- const close = () => { -- if (leaving) return; -- setLeaving(true); -- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS); -- }; -- useEscape(close); -- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; -- const found = useSessions((list) => { -- if (wanted.length < 2) return "[]"; -- const hits = []; -- for (const id of ids) { -- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; -- if (!Array.isArray(outline)) continue; -- const hidden = roster.exchangeTurns[id] ?? []; -- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) { -- const entry = outline[index2]; -- if (hidden.includes(entry.turn)) continue; -- for (const raw of [entry.response, entry.prompt]) { -- const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); -- const at = text.toLowerCase().indexOf(wanted); -- if (at < 0) continue; -- const from = Math.max(0, at - 24); -- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); -- break; ++ }, []); + (0, import_react21.useEffect)(() => { + if (typeof window === "undefined") return void 0; + if (!hash.startsWith("#project")) { @@ -15219,10 +15167,10 @@ diff --git a/client.js b/client.js + const newer = teamGen.current !== gen; + if (next.teamFresh !== false && !newer) team.current = next.team; + setState(newer && team.current !== void 0 ? { ...next, team: team.current } : next); - } ++ } + } catch { + if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] }); - } ++ } + }; + void refresh(true); + const timer = setInterval(() => { @@ -15247,8 +15195,10 @@ diff --git a/client.js b/client.js + setState((previous) => previous === null ? previous : { ...previous, team: answer }); + } catch { } -- return JSON.stringify(hits); -+ }; +- if (!spokeAt) return "waiting"; +- if (level < quiet) quietSince ||= now; +- return quietSince && now - quietSince >= quietMs ? "done" : "speech"; + }; + if (state === null) return null; + return (0, import_react21.createElement)( + "div", @@ -15319,8 +15269,20 @@ diff --git a/client.js b/client.js + ) + ) + ); -+} -+ + } + +-// src/client/overlay-hooks.js +-var import_react11 = require("react"); +-var escapeLayers = []; +-var onEscapeKey = (event) => { +- if (event.key === "Escape") escapeLayers.at(-1)?.current(); +-}; +-function useEscape(onClose) { +- const ref = (0, import_react11.useRef)(onClose); +- ref.current = onClose; +- (0, import_react11.useEffect)(() => { +- if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); +- escapeLayers.push(ref); +// src/client/header.js +var import_react22 = require("react"); +var import_react_dom6 = require("react-dom"); @@ -15358,19 +15320,7 @@ diff --git a/client.js b/client.js + const firstTurns = useSessions((list) => { + const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; + return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : ""; - }); -- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); -- const sections = []; -- const people = [ -- ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ -- key: bot.id, -- avatar: (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), -- title: bot.name, -- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), -- badge: bot.hidden ? t("Hidden") : void 0, -- run: async () => { -- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); -- actions.openSession(bot.id); ++ }); + const hiddenTurns = roster.exchangeTurns?.[sessionId]; + const visit = useUi((state) => state.visits?.[sessionId]); + const opening = useTurnClock((state) => { @@ -15439,7 +15389,193 @@ diff --git a/client.js b/client.js + observer.disconnect(); + watched = scroller; + if (scroller) observer.observe(scroller, { childList: true, subtree: true }); ++ } ++ if (!scroller) { ++ setView(null); ++ return; ++ } ++ const box = scroller.getBoundingClientRect(); ++ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; ++ const sep = scroller.querySelector(".bt-new-sep"); ++ let divider = null; ++ if (sep) { ++ const at = sep.getBoundingClientRect(); ++ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; ++ } ++ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; ++ const next = { ++ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, ++ divider, ++ center: Math.round(box.left + box.width / 2), ++ top: Math.round(headerBottom + 8), ++ bottom: Math.round(window.innerHeight - box.bottom + composer + 8) ++ }; ++ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); ++ }; ++ const schedule = () => { ++ if (!frame) frame = requestAnimationFrame(measure); ++ }; ++ document.addEventListener("scroll", schedule, true); ++ window.addEventListener("resize", schedule); ++ schedule(); + return () => { +- escapeLayers.splice(escapeLayers.indexOf(ref), 1); +- if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ document.removeEventListener("scroll", schedule, true); ++ window.removeEventListener("resize", schedule); + }; + }, []); ++ return view; + } +-function usePaneLeft() { +- const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); +- (0, import_react11.useLayoutEffect)(() => { +- const column = document.querySelector('[class*="_footArea"]')?.parentElement; +- if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); +- }, []); +- return paneLeft; ++function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { ++ const replies = useSessions((list) => { ++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; ++ return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; ++ }); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const unread = useTurnClock((state) => { ++ if (typeof visit?.seen !== "number") return 0; ++ let count = 0; ++ for (const entry of Object.values(state[sessionId] ?? {})) { ++ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; ++ } ++ return count; ++ }); ++ const view = useTranscriptView(); ++ const unpinned = view?.unpinned === true; ++ const [release, setRelease] = (0, import_react22.useState)({ base: null, dismissed: false }); ++ const [above, setAbove] = (0, import_react22.useState)({ seen: false, dismissed: false }); ++ (0, import_react22.useEffect)(() => { ++ setRelease({ base: unpinned ? replies : null, dismissed: false }); ++ }, [unpinned]); ++ (0, import_react22.useEffect)(() => { ++ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); ++ }, [view?.divider]); ++ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); ++ (0, import_react22.useEffect)(() => { ++ if (above.seen && movedOn) actions?.settleVisit?.(sessionId); ++ }, [above.seen, movedOn, sessionId]); ++ const fresh = release.base === null ? 0 : replies - release.base; ++ const showDown = unpinned && fresh > 0 && !release.dismissed; ++ const showUp = view?.divider === "above" && !above.seen && !above.dismissed; ++ (0, import_react22.useEffect)(() => { ++ const scroller = document.querySelector("[data-conversation-scroll]"); ++ if (!scroller) return void 0; ++ scroller.toggleAttribute("data-bt-news", showDown); ++ return () => scroller.removeAttribute("data-bt-news"); ++ }, [showDown]); ++ if (!view || !showDown && !showUp) return null; ++ const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); ++ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react22.createElement)( ++ "div", ++ { className: "bt-news", "data-direction": direction, style }, ++ (0, import_react22.createElement)( ++ "button", ++ { type: "button", className: "bt-news-jump", onClick: onJump }, ++ (0, import_react22.createElement)("span", { className: "bt-news-ico" }, (0, import_react22.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), ++ label(count) ++ ), ++ (0, import_react22.createElement)("button", { ++ type: "button", ++ className: "bt-news-x", ++ "aria-label": t("Dismiss new messages"), ++ onClick: (event) => { ++ event.stopPropagation(); ++ onDismiss(); ++ } ++ }, (0, import_react22.createElement)(CloseIcon)) ++ ); ++ const toLatest = () => { ++ const scroller = document.querySelector("[data-conversation-scroll]"); ++ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); ++ if (back) back.click(); ++ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); ++ }; ++ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); ++ return (0, import_react_dom6.createPortal)( ++ (0, import_react22.createElement)( ++ import_react22.Fragment, ++ null, ++ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, ++ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null ++ ), ++ document.body ++ ); + } + +-// src/client/palette.js +-var import_react12 = require("react"); +-var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); +-var PALETTE_EXIT_MS = 160; +-function CommandPalette({ start, token, roster, actions, useSessions }) { +- const [query, setQuery] = (0, import_react12.useState)(start ?? ""); +- const [cursor, setCursor] = (0, import_react12.useState)(0); +- const [leaving, setLeaving] = (0, import_react12.useState)(false); +- const wanted = query.trim().toLowerCase(); +- const close = () => { +- if (leaving) return; +- setLeaving(true); +- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS); +- }; +- useEscape(close); +- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; +- const found = useSessions((list) => { +- if (wanted.length < 2) return "[]"; +- const hits = []; +- for (const id of ids) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) continue; +- const hidden = roster.exchangeTurns[id] ?? []; +- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) { +- const entry = outline[index2]; +- if (hidden.includes(entry.turn)) continue; +- for (const raw of [entry.response, entry.prompt]) { +- const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); +- const at = text.toLowerCase().indexOf(wanted); +- if (at < 0) continue; +- const from = Math.max(0, at - 24); +- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]); +- break; +- } ++// src/client/composer-label.js ++var ATTRIBUTES = ["aria-label", "data-placeholder"]; ++function holdComposerLabel(doc, label) { ++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); ++ const shell = /* @__PURE__ */ new Map(); ++ const apply2 = () => { ++ for (const el of doc.querySelectorAll("[data-composer-input]")) { ++ for (const name of ATTRIBUTES) { ++ const current = el.getAttribute(name); ++ if (current === label) continue; ++ if (!shell.has(el)) shell.set(el, {}); ++ shell.get(el)[name] = current; ++ el.setAttribute(name, label); } + } +- return JSON.stringify(hits); +- }); +- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); +- const sections = []; +- const people = [ +- ...roster.bots.filter((bot) => matchesName(bot.name, bot.role)).map((bot) => ({ +- key: bot.id, +- avatar: (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), +- title: bot.name, +- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), +- badge: bot.hidden ? t("Hidden") : void 0, +- run: async () => { +- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); +- actions.openSession(bot.id); +- } - })), - ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ - key: room2.id, @@ -15450,9 +15586,18 @@ diff --git a/client.js b/client.js - run: async () => { - if (room2.hidden) await actions.setFlags(room2.id, { hidden: false }); - actions.openSession(room2.id); -+ if (!scroller) { -+ setView(null); -+ return; ++ }; ++ apply2(); ++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; ++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; ++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); ++ return () => { ++ watcher?.disconnect(); ++ for (const [el, values] of shell) { ++ if (!el.isConnected) continue; ++ for (const [name, value] of Object.entries(values)) { ++ if (value == null) el.removeAttribute(name); ++ else el.setAttribute(name, value); } - })) - ]; @@ -15474,44 +15619,8 @@ diff --git a/client.js b/client.js - subtitle: snippet, - avatar: bot ? (0, import_react12.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), - run: () => actions.openSession(id) -+ const box = scroller.getBoundingClientRect(); -+ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50; -+ const sep = scroller.querySelector(".bt-new-sep"); -+ let divider = null; -+ if (sep) { -+ const at = sep.getBoundingClientRect(); -+ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view"; -+ } -+ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152; -+ const next = { -+ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null, -+ divider, -+ center: Math.round(box.left + box.width / 2), -+ top: Math.round(headerBottom + 8), -+ bottom: Math.round(window.innerHeight - box.bottom + composer + 8) -+ }; -+ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next); -+ }; -+ const schedule = () => { -+ if (!frame) frame = requestAnimationFrame(measure); -+ }; -+ document.addEventListener("scroll", schedule, true); -+ window.addEventListener("resize", schedule); -+ schedule(); -+ return () => { -+ cancelAnimationFrame(frame); -+ observer.disconnect(); -+ document.removeEventListener("scroll", schedule, true); -+ window.removeEventListener("resize", schedule); - }; -+ }, []); -+ return view; -+} -+function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) { -+ const replies = useSessions((list) => { -+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -+ return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0; - }); +- }; +- }); - if (messages.length) sections.push([t("Messages"), messages]); - const commands = [ - { key: "cmd:new", icon: (0, import_react12.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, @@ -15534,45 +15643,11 @@ diff --git a/client.js b/client.js - if (!row || leaving) return; - void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); - close(); -- }; ++ } + }; - let index = -1; - return (0, import_react12.createElement)( -+ const visit = useUi((state) => state.visits?.[sessionId]); -+ const unread = useTurnClock((state) => { -+ if (typeof visit?.seen !== "number") return 0; -+ let count = 0; -+ for (const entry of Object.values(state[sessionId] ?? {})) { -+ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1; -+ } -+ return count; -+ }); -+ const view = useTranscriptView(); -+ const unpinned = view?.unpinned === true; -+ const [release, setRelease] = (0, import_react22.useState)({ base: null, dismissed: false }); -+ const [above, setAbove] = (0, import_react22.useState)({ seen: false, dismissed: false }); -+ (0, import_react22.useEffect)(() => { -+ setRelease({ base: unpinned ? replies : null, dismissed: false }); -+ }, [unpinned]); -+ (0, import_react22.useEffect)(() => { -+ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); -+ }, [view?.divider]); -+ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); -+ (0, import_react22.useEffect)(() => { -+ if (above.seen && movedOn) actions?.settleVisit?.(sessionId); -+ }, [above.seen, movedOn, sessionId]); -+ const fresh = release.base === null ? 0 : replies - release.base; -+ const showDown = unpinned && fresh > 0 && !release.dismissed; -+ const showUp = view?.divider === "above" && !above.seen && !above.dismissed; -+ (0, import_react22.useEffect)(() => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ if (!scroller) return void 0; -+ scroller.toggleAttribute("data-bt-news", showDown); -+ return () => scroller.removeAttribute("data-bt-news"); -+ }, [showDown]); -+ if (!view || !showDown && !showUp) return null; -+ const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); -+ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react22.createElement)( - "div", +- "div", - { className: "bt-palette-layer", "data-leaving": leaving || void 0 }, - (0, import_react12.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), - (0, import_react12.createElement)( @@ -15657,39 +15732,7 @@ diff --git a/client.js b/client.js - )) - ) - ) -+ { className: "bt-news", "data-direction": direction, style }, -+ (0, import_react22.createElement)( -+ "button", -+ { type: "button", className: "bt-news-jump", onClick: onJump }, -+ (0, import_react22.createElement)("span", { className: "bt-news-ico" }, (0, import_react22.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -+ label(count) -+ ), -+ (0, import_react22.createElement)("button", { -+ type: "button", -+ className: "bt-news-x", -+ "aria-label": t("Dismiss new messages"), -+ onClick: (event) => { -+ event.stopPropagation(); -+ onDismiss(); -+ } -+ }, (0, import_react22.createElement)(CloseIcon)) -+ ); -+ const toLatest = () => { -+ const scroller = document.querySelector("[data-conversation-scroll]"); -+ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button'); -+ if (back) back.click(); -+ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" }); -+ }; -+ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); -+ return (0, import_react_dom6.createPortal)( -+ (0, import_react22.createElement)( -+ import_react22.Fragment, -+ null, -+ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, -+ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null -+ ), -+ document.body - ); +- ); } -// src/client/model-menu.js @@ -15720,37 +15763,7 @@ diff --git a/client.js b/client.js - const served = servedOf(roster, ref); - const [provider, ...rest] = ref.split("/"); - return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; -+// src/client/composer-label.js -+var ATTRIBUTES = ["aria-label", "data-placeholder"]; -+function holdComposerLabel(doc, label) { -+ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label)); -+ const shell = /* @__PURE__ */ new Map(); -+ const apply2 = () => { -+ for (const el of doc.querySelectorAll("[data-composer-input]")) { -+ for (const name of ATTRIBUTES) { -+ const current = el.getAttribute(name); -+ if (current === label) continue; -+ if (!shell.has(el)) shell.set(el, {}); -+ shell.get(el)[name] = current; -+ el.setAttribute(name, label); -+ } -+ } -+ }; -+ apply2(); -+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; -+ const watcher = typeof Observer === "function" ? new Observer(apply2) : null; -+ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES }); -+ return () => { -+ watcher?.disconnect(); -+ for (const [el, values] of shell) { -+ if (!el.isConnected) continue; -+ for (const [name, value] of Object.entries(values)) { -+ if (value == null) el.removeAttribute(name); -+ else el.setAttribute(name, value); -+ } -+ } -+ }; - } +-} -var effortCache = /* @__PURE__ */ new Map(); -function useEfforts(served, ref, actions) { - const [fetched, setFetched] = (0, import_react13.useState)(void 0); @@ -15764,7 +15777,6 @@ diff --git a/client.js b/client.js - effortCache.set(ref, reasoning); - if (live) setFetched(reasoning); - }).catch(() => { -+ +// src/client/header-effects.js +function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) { + if (!doc) return () => { @@ -20686,7 +20698,7 @@ diff --git a/client.js b/client.js + key: `${item.from.id}>${item.to.id}`, + back: true, + d: `M ${x1} ${y1} L ${x1} ${yA} L ${lane} ${yA} L ${lane} ${yB} L ${x2} ${yB} L ${x2} ${y2}` -+ }); + }); + taken.push({ x: lane, yA: Math.min(yA, yB), yB: Math.max(yA, yB) }); + }); + const centres = []; @@ -20712,7 +20724,7 @@ diff --git a/client.js b/client.js + key: `${item.from.id}>${item.to.id}`, + back: false, + d: straight ? `M ${x1} ${y1} L ${x1} ${y2}` : `M ${x1} ${y1} L ${x1} ${yA} L ${lane} ${yA} L ${lane} ${yB} L ${x2} ${yB} L ${x2} ${y2}` - }); ++ }); + } + return routes; +} @@ -22260,16 +22272,16 @@ diff --git a/client.js b/client.js { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react25.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) + (0, import_react39.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) - ); --var MARKS = { github: GitHubMark }; --var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; ++); +var FeishuMark = () => (0, import_react39.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + (0, import_react39.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), + (0, import_react39.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), + (0, import_react39.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) -+); + ); +-var MARKS = { github: GitHubMark }; +-var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; +var ABOUT = { + github: () => t("Repositories, issues, and pull requests"), @@ -23645,22 +23657,22 @@ diff --git a/client.js b/client.js - const groups = [ + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const groups = bot?.remote ? [ -+ [ -+ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] -+ ], -+ [ -+ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)] -+ ], -+ pluginItems(structure, bot, actions, roster).map(([label, run]) => [label, LinkIcon, run]), -+ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] -+ ] : [ [ - isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], -+ isTile || locked ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] ], [ - bot ? [t("Rename Bot"), PencilIcon, () => actions.renameBot(bot.id)] : null, ++ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)] ++ ], ++ pluginItems(structure, bot, actions, roster).map(([label, run]) => [label, LinkIcon, run]), ++ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]] ++ ] : [ ++ [ ++ isTile || locked ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })], ++ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)] ++ ], ++ [ + // One way to edit a Bot (#385): its name is edited in Edit Bot too. bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null, room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null, @@ -23796,11 +23808,10 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11033,249 +11033,13 @@ function migrateStorage(storage = globalThis.localStorage) { - const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null"); +@@ -11034,248 +11034,12 @@ function migrateStorage(storage = globalThis.localStorage) { if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) { storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] })); -- } + } - } catch { - } -} @@ -23970,7 +23981,6 @@ diff --git a/client.js b/client.js - (0, import_react30.createElement)("div", { className: "bt-mw-state bt-mw-ask" }, `${t("Waiting for your approval")}${since(now, entry.approvals[0].at)}`), - entry.approvals.map((approval) => (0, import_react30.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) - ); -+ } + } catch { } - if (entry.state !== "work") return null; @@ -24139,7 +24149,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,207 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,211 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -24217,13 +24227,17 @@ diff --git a/client.js b/client.js + "div", + { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, + (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react44.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), -+ items.map((item) => (0, import_react44.createElement)( -+ "div", -+ { key: item.id, className: "bt-set-row" }, -+ (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), -+ locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), -+ locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) -+ )), ++ items.map((item) => { ++ const at = typeof item.createdAt === "number" ? item.createdAt : Date.parse(item.createdAt); ++ const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; ++ return (0, import_react44.createElement)( ++ "div", ++ { key: item.id, className: "bt-set-row" }, ++ (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply"), age), ++ locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), ++ locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) ++ ); ++ }), + error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert" }, error) : null + ); +} diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index a7463227f..c167cebb2 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -114,7 +114,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { isMainOf } from './sources.js' -import { HEADER, currentPart, stripHeader, usageOf } from './text.js' -+import { copyNames, currentPart, transcriptOf, usageOf } from './text.js' ++import { copyNames, currentPart, relativeShort, transcriptOf, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' -import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' +import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' @@ -150,7 +150,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js const TAB_LABELS = { details: 'Details', browser: 'Browser' } -@@ -113,16 +113,121 @@ function About({ text, title = 'About' }) { +@@ -113,16 +113,127 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. @@ -192,14 +192,20 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + } + return h('div', { key: 'approvals', className: 'bt-shared-wait', 'data-flybot': 'room-pending-approvals' }, + h('div', { className: 'bt-section-title' }, t('Waiting for your approval'), h('span', { className: 'bt-chip' }, t('{count} to approve', { count: items.length }))), -+ items.map(item => h('div', { key: item.id, className: 'bt-set-row' }, -+ h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply')), -+ locked -+ ? null -+ : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), -+ locked -+ ? null -+ : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))), ++ items.map((item) => { ++ // #2409: the plugin's view carries the wait's `createdAt` as an ISO string or not at all ++ // (`plugins/social/src/social.js` approvalView); anything unparseable draws nothing. ++ const at = typeof item.createdAt === 'number' ? item.createdAt : Date.parse(item.createdAt) ++ const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null ++ return h('div', { key: item.id, className: 'bt-set-row' }, ++ h('span', { className: 'bt-set-copy' }, item.text || item.summary || item.to || t('Outbound reply'), age), ++ locked ++ ? null ++ : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'approve') }, t('Approve')), ++ locked ++ ? null ++ : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject'))) ++ }), + error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null) +} + diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 5673c4744..7bc94ceea 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -47,6 +47,7 @@ import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-cla import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { lockedWindow } from './schedules.js' +import { relativeShort } from './text.js' import { isMainOf } from './sources.js' import { createClientApi as createClientApiWith } from './flybot-registry.js' @@ -331,8 +332,11 @@ export function FlyBotApprovals({ useRoster, actions } = {}) { if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call, readOnly: locked === true } })) const failed = approval.status === 'failed' const waiting = isApprovalWaiting(approval.status) + // #2409: one rule for all three surfaces — a number of ms, an ISO string, or nothing drawable. + const at = typeof approval.createdAt === 'number' ? approval.createdAt : Date.parse(approval.createdAt) + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null return h('div', { key: approval.id, className: 'bt-flybot-approval', title: approval.reason ?? undefined }, - h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval')), + h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval'), waiting ? age : null), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: approval.senderName ?? t('A Bot'), to: recipientName(approval, roster) }), approval.request.hop > 0 ? ` · ${t('delegated')}` : ''), diff --git a/patches/ds-bot/files/src/client/held-reply.js b/patches/ds-bot/files/src/client/held-reply.js index 98400170f..ad0a85068 100644 --- a/patches/ds-bot/files/src/client/held-reply.js +++ b/patches/ds-bot/files/src/client/held-reply.js @@ -11,6 +11,7 @@ import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' import { lockedWindow } from './schedules.js' import { hostText, t } from './i18n.js' +import { relativeShort } from './text.js' const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payload) @@ -61,8 +62,11 @@ export function HeldReplyActions({ held, actions, roster }) { failed && canRetry(held) ? (locked ? readOnlyLine() : h('button', { type: 'button', className: 'bt-held-retry', disabled: busy, onClick: () => decide('retry') }, t('Retry'))) : null, error ? h('span', { className: 'bt-flybot-approval-error', role: 'alert' }, error) : null) } + // #2409: same rule as the dock and the room card; an absent or unparseable stamp draws nothing. + const at = typeof held.createdAt === 'number' ? held.createdAt : Date.parse(held.createdAt) + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null const plain = h('div', { className: 'bt-held-card', role: 'region', 'aria-label': t('Waiting for your approval') }, - h('div', { className: 'bt-held-head' }, t('Waiting for your approval')), + h('div', { className: 'bt-held-head' }, t('Waiting for your approval'), age), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: held.from ?? t('A Bot'), to: held.toName })), locked ? readOnlyLine() : h('div', { className: 'bt-flybot-approval-actions' }, h('button', { type: 'button', disabled: busy, onClick: () => decide('reject') }, t('Reject')), diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index f3987dffb..ee1e51044 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -561,6 +561,8 @@ export function install(rt) { .map(a => ({ id: a.id, by: a.by, + // #2409: the bubble draws how long the hold has waited, from the record's own stamp. + createdAt: a.createdAt, status: SENT.has(a.status) || isApprovalWaiting(a.status) || a.status === 'failed' ? a.status : 'rejected', sessionId: a.request.meta.replyIn.sessionId, turn: a.request.meta.replyIn.turn, diff --git a/patches/ds-bot/files/test/approval-age.test.js b/patches/ds-bot/files/test/approval-age.test.js new file mode 100644 index 000000000..9c889f8ea --- /dev/null +++ b/patches/ds-bot/files/test/approval-age.test.js @@ -0,0 +1,226 @@ +// #2409: an approval card never said how long it had been waiting, so a hold from half an hour ago +// and one that had just arrived looked identical. The host has written `createdAt` on every hold +// since the start (`core/src/host/hooks.js` hold), and the room's card receives the plugin's +// `createdAt` (an ISO string or absent), but none of the three drawing points read it. They now do, +// through `relativeShort()` and the four strings already in `i18n.js`: `Waiting for approval · 12 min`. +// React is replaced by the tiny renderer the other client tests use (no jsdom here). +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +// The drawer keeps a `useRef` object and memoises values, so these three answer the way React does. +reactStub.useRef = (value) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } + return inst.hooks[i] +} +reactStub.useMemo = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn() } + return inst.hooks[i].value +} +reactStub.useCallback = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn } + return inst.hooks[i].value +} +for (const name of ['useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +globalThis.__hostCall = () => Promise.resolve([]) +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + if (specifier === 'react-dom') return { url: 'data:text/javascript,export const createPortal = node => node', shortCircuit: true } + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return { url: 'data:text/javascript,export const MarkdownText = node => node;export const FishLogo = node => node', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +function render(Component, inst) { + live.inst = inst + live.idx = 0 + inst.effects = [] + const out = Component(inst.props ?? {}) + inst.rendered = true + return out +} +// The drawer returns elements; React draws the function components inside them, so they are +// expanded here the same way the room-approvals test draws it. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function' && !node.type.prototype?.render) return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.props?.children ?? node.children) +} + +// 12 minutes is the issue's own example: `relativeShort` floors it to `12 min`. +const TWELVE = 12 * 60 * 1000 +const twelveAgo = () => Date.now() - TWELVE + +const host = { id: 'b1', name: 'Helper', ownerId: 'm1', sessionId: 's1' } +const writable = { self: { memberId: 'm1' }, bots: [host], byId: { b1: host }, rooms: [], roomsById: {}, readOnly: '', exchangeTurns: {}, prefs: {}, mainBotIds: [], connectors: [], memory: false, questions: {} } +const approval = (extra = {}) => ({ + id: 'a1', status: 'pending', by: 'tree', senderName: 'Helper', targetName: '@chief.uia.home', + request: { from: 'bot-helper', to: { handle: '@chief.uia.home' }, text: 'Which file?', hop: 0 }, + ...extra, +}) +const room = (pendingApprovals) => ({ + id: 'r1', name: 'Team room', origin: 'group', viewerRole: 'invited-owner', pendingCount: pendingApprovals.length, + pendingApprovals, members: [], participants: [], +}) +const map = () => new Map() +const fakeList = { byId: {}, projectionsBySession: {} } +const actions = { + closeOverlay() {}, openSession() {}, openSettings() {}, openBrowser() {}, + updateRoom: () => Promise.resolve(), notify() {}, reportError() {}, +} +const drawer = (pendingApprovals, rosterExtra = {}) => { + const r = room(pendingApprovals) + return { + hooks: {}, effects: [], rendered: false, + props: { + sessionId: 'r1', startTab: 'details', hasStrip: false, roster: { ...writable, ...rosterExtra, roomsById: { r1: r }, rooms: [r] }, actions, + useSessions: (pick) => pick(fakeList), useSessionStatus: (pick) => pick(map()), + useActivity: (pick) => pick(map()), useWork: (pick) => pick(map()), + useBrowserTabs: (pick) => pick(map()), useBrowserPages: (pick) => pick(map()), + }, + } +} + +// The read-only window is the one the host draws without writes: `lockedWindow()` reads this string. +const lockedRoster = { ...writable, readOnly: 'read-only window' } +// A card is found by one of its own class names, so the assertions below cannot drift onto a +// neighbouring surface: exactly one card, and only its buttons are read. +const findAll = (node, keep, out = []) => { + if (Array.isArray(node)) { for (const child of node) findAll(child, keep, out); return out } + if (!node || typeof node !== 'object') return out + if (keep(node)) out.push(node) + findAll(node.props?.children, keep, out) + return out +} +const card = (node, marker) => { + const found = findAll(node, n => typeof n?.props?.className === 'string' && n.props.className.split(' ').includes(marker)) + assert.equal(found.length, 1, `exactly one ${marker} card`) + return found[0] +} +const buttonLabels = (node) => findAll(node, n => n?.type === 'button').map(n => text(n)) +const decisions = (node) => buttonLabels(node).filter(label => ['Approve', 'Reject', 'Retry'].includes(label)) +const countOf = (hay, needle) => hay.split(needle).length - 1 + +// Hook slots of FlyBotApprovals: useSlot x2 = 0-3, claimed = 4-5, pending = 6. +const PENDING = 6 +const dock = (list, roster = writable) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { actions: {}, useRoster: pick => pick(roster) } } + render(FlyBotApprovals, inst) + inst.hooks[PENDING].value = list + return render(FlyBotApprovals, inst) +} +const bubble = (held, roster = writable) => render(HeldReplyActions, { hooks: {}, effects: [], rendered: false, props: { held, actions: {}, roster } }) +const held = (extra = {}) => ({ id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', ...extra }) + +let FlyBotApprovals +let HeldReplyActions +let DetailsDrawer +test.before(async () => { + ;({ FlyBotApprovals } = await import('../src/client/flybot-slots.js')) + ;({ HeldReplyActions } = await import('../src/client/held-reply.js')) + ;({ DetailsDrawer } = await import('../src/client/details-drawer.js')) +}) + +test('#2409: the dock card says how long the hold has waited', () => { + const drawn = dock([approval({ createdAt: twelveAgo() })]) + assert.match(text(drawn), /Waiting for approval · 12 min/, 'the head line carries the age') +}) + +test('#2409: the held-reply bubble says how long the hold has waited', () => { + const drawn = bubble(held({ createdAt: twelveAgo() })) + assert.match(text(drawn), /Waiting for your approval · 12 min/, 'the head line carries the age') +}) + +test('#2409: the room overview card says how long the hold has waited', () => { + const drawn = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?', createdAt: new Date(twelveAgo()).toISOString() }]))) + assert.match(text(drawn), /Which file\? · 12 min/, 'the row carries the age') +}) + +test('#2409: a card without a createdAt draws no age at all', () => { + const openedDock = dock([approval()]) + assert.doesNotMatch(text(openedDock), /\d+ min|just now|Invalid Date|NaN/, 'the dock draws no age when the field is missing') + const openedBubble = bubble(held()) + assert.doesNotMatch(text(openedBubble), /\d+ min|just now/, 'the bubble draws no age when the field is missing') + const openedRoom = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?' }]))) + assert.doesNotMatch(text(openedRoom), /\d+ min|just now/, 'the room row draws no age when the field is missing') +}) + +// #2409 must-fix: a truthiness test drew " · Invalid Date" for any non-empty junk string. The +// three surfaces now share one rule, so these two cases must read exactly like the missing field — +// byte for byte, not merely "no digits", which is what let the unfixed code stay green. +test('#2409: an unparseable createdAt draws exactly what a missing one draws', () => { + const missing = text(dock([approval()])) + for (const junk of ['not-a-date', 'NaN']) { + const drawn = text(dock([approval({ createdAt: junk })])) + assert.doesNotMatch(drawn, /Invalid Date|NaN/, `the dock draws no junk for ${junk}`) + assert.equal(drawn, missing, `the dock is unchanged by ${junk}`) + } + const missingBubble = text(bubble(held())) + for (const junk of ['not-a-date', 'NaN']) { + const drawn = text(bubble(held({ createdAt: junk }))) + assert.doesNotMatch(drawn, /Invalid Date|NaN/, `the bubble draws no junk for ${junk}`) + assert.equal(drawn, missingBubble, `the bubble is unchanged by ${junk}`) + } +}) + +// Product's finding: the read-only path was only covered by items without `createdAt`, so a +// regression that dropped the age in a locked window stayed silence. This pins all three faces: +// exactly one age, and no decision button at all. +test('#2409: a read-only window still says how long the hold has waited', () => { + const docked = card(dock([approval({ createdAt: twelveAgo() })], lockedRoster), 'bt-flybot-approval') + assert.equal(countOf(text(docked), ' · 12 min'), 1, 'the dock head carries exactly one age') + assert.match(text(docked), /Waiting for approval · 12 min/) + assert.deepEqual(decisions(docked), [], 'a read-only dock card offers no decision') + assert.deepEqual(buttonLabels(docked), [], 'a read-only dock card is not pressable') + + const bubbled = card(bubble(held({ createdAt: twelveAgo() }), lockedRoster), 'bt-held-actions') + assert.equal(countOf(text(bubbled), ' · 12 min'), 1, 'the bubble head carries exactly one age') + assert.match(text(bubbled), /Waiting for your approval · 12 min/) + assert.deepEqual(decisions(bubbled), [], 'a read-only bubble offers no decision') + assert.deepEqual(buttonLabels(bubbled), [], 'a read-only bubble is not pressable') + + const opened = expand(render(DetailsDrawer, drawer([{ id: 'a1', text: 'Which file?', createdAt: new Date(twelveAgo()).toISOString() }], { readOnly: 'read-only window' }))) + const roomCard = card(opened, 'bt-shared-wait') + assert.equal(countOf(text(roomCard), ' · 12 min'), 1, 'the room row carries exactly one age') + assert.match(text(roomCard), /Which file\? · 12 min/) + assert.deepEqual(decisions(roomCard), [], 'a read-only room card offers no decision') + assert.deepEqual(buttonLabels(roomCard), [], 'a read-only room card is not pressable') +})