From 2c308792b580d5c865950aeb4d27247c24b87589 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 04:39:10 +0000 Subject: [PATCH] Draw each face-room member with their chat look Room details painted one letter S for every home that shares an initial. Member rows now use the same Bot mark as the transcript and the sidebar. Fixes #1104 Co-authored-by: Danyang Chen --- core/client.js | 40 +- core/src/client/details-drawer.js | 2 +- core/src/client/group-settings.js | 53 +- core/test/face-member-avatar.test.js | 143 + core/test/face-person-first.test.js | 7 +- docs/subsystems/core-client.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 3047 +++++++++-------- patches/ds-bot/0012-room-membership.patch | 61 +- .../files/test/face-member-avatar.test.js | 143 + .../files/test/face-person-first.test.js | 7 +- 10 files changed, 1979 insertions(+), 1526 deletions(-) create mode 100644 core/test/face-member-avatar.test.js create mode 100644 patches/ds-bot/files/test/face-member-avatar.test.js diff --git a/core/client.js b/core/client.js index 73b328ad..60660e72 100644 --- a/core/client.js +++ b/core/client.js @@ -12018,6 +12018,37 @@ var ROOM_MODES = [ ["everyone", "Everyone", "Every member answers in turn, admin first."] ]; var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; +function faceSeatBot(seat, roster) { + if (!seat) return void 0; + const record = seat.record && typeof seat.record === "object" ? seat.record : {}; + const extra = {}; + if (typeof record.color === "string" && record.color) extra.color = record.color; + if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar; + if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local; + const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : ""; + return lookBotForRemote({ + handle, + displayName: record.displayName || seat.bot, + name: seat.bot, + label: record.label, + id: record.id || seat.key, + ...extra + }, roster); +} +function faceMemberLook(bot) { + if (!bot) return void 0; + return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot); +} +function faceMarks(person, roster, size) { + const marks = []; + for (const seat of person?.seats ?? []) { + const bot = faceSeatBot(seat, roster); + if (!bot) continue; + marks.push((0, import_react26.createElement)(BotMark, { key: seat.key, bot, size, live: false })); + } + if (marks.length === 0) return [(0, import_react26.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + return marks; +} function faceRoomCount(room2, roster) { const people2 = facePeople(room2, roster).length; const seats = faceSeats(room2, roster).length; @@ -12027,7 +12058,7 @@ function faceRoomCount(room2, roster) { } var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead)); -function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { +function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { const bots = person.seats.flatMap((seat, index) => { const name = seat.local && onOpenBot ? (0, import_react26.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react26.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); const marked = isLeadSeat(seat, lead) ? [name, (0, import_react26.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; @@ -12039,7 +12070,7 @@ function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs- (0, import_react26.createElement)( "span", { className: "bt-gs-who bt-face-person" }, - (0, import_react26.createElement)(PersonMark, { size, name: person.name }), + (0, import_react26.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), (0, import_react26.createElement)( "span", { className: "bt-face-person-text" }, @@ -12209,7 +12240,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { face ? (0, import_react26.createElement)( Card, { title: t("Members · {count}", { count: people2.length }) }, - people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) ) : null, face ? null : (0, import_react26.createElement)( Card, @@ -12406,6 +12437,8 @@ var GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} +.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px} +.bt-face-marks>.bt-mark{flex:none} .bt-face-person{cursor:default} .bt-face-person:hover .bt-row-title{text-decoration:none} .bt-face-person-text{display:flex;flex-direction:column;min-width:0} @@ -15747,6 +15780,7 @@ function FaceMembers({ room: room2, roster, actions }) { (0, import_react38.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react38.createElement)(FacePersonRow, { key: person.key, person, + roster, className: "bt-member", onOpenBot: (id) => actions.openSession(id), lead: room2.face?.lead?.handle diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index d0c1dac3..e30f299e 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -159,7 +159,7 @@ function FaceMembers({ room, roster, actions }) { h(FaceLead, { room, roster, pick }), h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: people.length })), h('div', { className: 'bt-members', role: 'list' }, people.map(person => h(FacePersonRow, { - key: person.key, person, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle, + key: person.key, person, roster, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle, })))) } diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index f047f625..b685eb27 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -1,7 +1,8 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' -import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookBotForRemote } from './mark.js' +import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookAvoidingLocals, lookBotForRemote, lookKey, lookOf } from './mark.js' +import { setLocalBots } from './characters.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' @@ -22,6 +23,48 @@ export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode import { faceSeats, faceSeatLabel, facePeople, facePersonLabel } from './face-seats.js' export { faceSeats, faceSeatLabel, facePeople } +export { setLocalBots, lookKey } + +// The Bot a member row draws. A local seat is the roster record the sidebar uses. A friend's +// seat is the chat record, with the face-room look winning, the same way a transcript mark is +// chosen. Two homes named SelfA… and SelfB… share an initial; the row must not. +export function faceSeatBot(seat, roster) { + if (!seat) return undefined + const record = seat.record && typeof seat.record === 'object' ? seat.record : {} + const extra = {} + if (typeof record.color === 'string' && record.color) extra.color = record.color + if (record.avatar && typeof record.avatar === 'object') extra.avatar = record.avatar + if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local + const handle = typeof record.handle === 'string' && record.handle.trim() + ? record.handle + : (typeof seat.key === 'string' && seat.key.startsWith('@') ? seat.key : '') + return lookBotForRemote({ + handle, + displayName: record.displayName || seat.bot, + name: seat.bot, + label: record.label, + id: record.id || seat.key, + ...extra, + }, roster) +} + +// The picture BotMark paints for that Bot: a friend's look steps off a same-named local Bot. +export function faceMemberLook(bot) { + if (!bot) return undefined + return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot) +} + +function faceMarks(person, roster, size) { + const marks = [] + for (const seat of person?.seats ?? []) { + const bot = faceSeatBot(seat, roster) + if (!bot) continue + marks.push(h(BotMark, { key: seat.key, bot, size, live: false })) + } + // No seat to draw is the silhouette, never an empty disc (#1089). + if (marks.length === 0) return [h(PersonMark, { key: 'person', size, name: person?.name })] + return marks +} // A face room says what it counts, people and Bots, not an ambiguous "2 members" (#306). export function faceRoomCount(room, roster) { @@ -38,7 +81,7 @@ export const isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(s // One row per person (#457): "Ann (you) · Chief, Researcher", my own Bots each opening its chat. // `lead`: the room lead's handle (#476), marked on its Bot. -export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) { +export function FacePersonRow({ person, onOpenBot, lead, roster, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) { const bots = person.seats.flatMap((seat, index) => { const name = seat.local && onOpenBot ? h('button', { key: seat.key, type: 'button', className: 'bt-face-bot', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) @@ -48,7 +91,7 @@ export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row }) return h('div', { className, role: 'listitem', 'data-flybot': 'face-person', 'data-mine': person.mine || undefined }, h('span', { className: 'bt-gs-who bt-face-person' }, - h(PersonMark, { size, name: person.name }), + h('span', { className: 'bt-face-marks', 'data-flybot': 'face-person-avatars' }, faceMarks(person, roster, size)), h('span', { className: 'bt-face-person-text' }, h('span', { className: 'bt-row-title' }, facePersonLabel(person)), h('span', { className: 'bt-face-bots' }, bots)))) @@ -168,7 +211,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), face ? h(Card, { title: t('Members · {count}', { count: people.length }) }, - people.map(person => h(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null, + people.map(person => h(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null, face ? null : h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) }, humans.map((person) => h('div', { key: person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, h('span', { className: 'bt-gs-who' }, @@ -295,6 +338,8 @@ export const GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} +.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px} +.bt-face-marks>.bt-mark{flex:none} .bt-face-person{cursor:default} .bt-face-person:hover .bt-row-title{text-decoration:none} .bt-face-person-text{display:flex;flex-direction:column;min-width:0} diff --git a/core/test/face-member-avatar.test.js b/core/test/face-member-avatar.test.js new file mode 100644 index 00000000..67e6a7c2 --- /dev/null +++ b/core/test/face-member-avatar.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #1104: room details drew one pink S for SelfA1025 and SelfB1025. The chat already +// tells those Chiefs apart (local whale, the friend's same whale stepped aside). + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const settings = await load('group-settings.js') +const { FacePersonRow } = settings +const { facePeople } = await load('face-seats.js') +const { personInitial } = await load('mark.js') + +const HANDLE = '@chief.selfa1025.home' +const chiefB = { id: 'b1', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } +const chiefA = { + id: 'remote-a', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, + remote: { handle: HANDLE, homeName: 'SelfA1025', owner: 'SelfA1025' }, +} +const seatB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' } +const seatA = { + kind: 'bot', remote: true, id: HANDLE, handle: HANDLE, displayName: 'Chief', ownerName: 'SelfA1025', + color: '#4d6bfe', avatar: { shape: 'whale' }, +} + +function roster(participants = [seatB, seatA], bots = [chiefB, chiefA]) { + return { + self: { displayName: 'SelfB1025' }, + bots, + byId: { b1: chiefB }, + roomsById: { r: { id: 'r', origin: 'face-to-face', name: '739184', members: ['b1'], participants } }, + } +} + +function rowsOf(view) { + settings.setLocalBots?.(view.bots) + const people = facePeople(view.roomsById.r, view) + return people.map(person => expand(FacePersonRow({ person, roster: view }), ['BotMark', 'PersonMark'])) +} +const shapesOf = row => find(row, n => typeof n.props?.['data-shape'] === 'string').map(n => n.props['data-shape']) + +test('#1104: SelfA1025 and SelfB1025 share an initial, so a letter mark cannot tell the homes apart', () => { + const view = roster() + const people = facePeople(view.roomsById.r, view) + // Copy out of the bundled realm; a vm array is not reference-equal to a host array. + assert.deepEqual(JSON.parse(JSON.stringify(people.map(person => person.name))), ['SelfB1025', 'SelfA1025']) + assert.equal(personInitial(people[0].name), 'S') + assert.equal(personInitial(people[1].name), 'S') +}) + +test('#1104: member rows draw each home\'s chat look, not the same initial', () => { + const drawn = rowsOf(roster()) + const shapes = drawn.map(shapesOf) + assert.deepEqual(shapes[0], ['whale'], 'my Chief stays the whale the sidebar and the transcript draw') + assert.equal(shapes[1].length, 1) + assert.notEqual(shapes[1][0], 'whale', 'the friend\'s same whale is stepped, as the chat mark is') + assert.equal(find(drawn, n => n.props?.['data-initial'] === 'S').length, 0) + for (const row of drawn) { + const mark = find(row, n => typeof n.props?.['data-shape'] === 'string')[0] + assert.ok(mark) + assert.equal(find(mark, n => n.type === 'svg').length, 1, 'a character, not a blank disc') + } + if (typeof settings.faceMemberLook === 'function' && typeof settings.faceSeatBot === 'function' && typeof settings.lookKey === 'function') { + const view = roster() + settings.setLocalBots(view.bots) + const people = facePeople(view.roomsById.r, view) + const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)))) + assert.match(keys[0], /^shape:whale:/) + assert.notEqual(keys[0], keys[1]) + assert.deepEqual(shapes, people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape])) + } +}) + +test('#1104: a seat with no stored look still draws a seeded character (#1089)', () => { + const bareA = { kind: 'bot', remote: true, id: '@chief.selfa1025.home', handle: '@chief.selfa1025.home', displayName: 'Chief', ownerName: 'SelfA1025' } + const bareB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' } + const plain = { id: 'b1', name: 'Chief' } + const drawn = rowsOf({ + self: { displayName: 'SelfB1025' }, + bots: [plain], + byId: { b1: plain }, + roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } }, + }) + const shapes = drawn.map(shapesOf) + assert.equal(shapes.length, 2) + for (const shape of shapes) { + assert.equal(shape.length, 1) + assert.match(shape[0], /^[a-z]/) + } + assert.equal(find(drawn, n => n.props?.['data-initial']).length, 0) + const view = { + self: { displayName: 'SelfB1025' }, + bots: [plain], + byId: { b1: plain }, + roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } }, + } + settings.setLocalBots(view.bots) + const people = facePeople(view.roomsById.r, view) + const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)))) + assert.equal(keys.length, 2) + assert.notEqual(keys[0], keys[1], 'a seeded shape or color still tells the homes apart') + const looked = people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape]) + assert.equal(JSON.stringify(shapes), JSON.stringify(looked)) +}) diff --git a/core/test/face-person-first.test.js b/core/test/face-person-first.test.js index d5b90c61..992b3fb3 100644 --- a/core/test/face-person-first.test.js +++ b/core/test/face-person-first.test.js @@ -73,13 +73,14 @@ test('one entry per person, mine first, "(you)" only on the person, count = peop assert.equal(roomSubtitle(roster.roomsById.r, roster), '2 people · 3 Bots') const opened = [] - const row = expand(FacePersonRow({ person: people[1], onOpenBot: id => opened.push(id) }), ['PersonMark']) + const row = expand(FacePersonRow({ person: people[1], roster, onOpenBot: id => opened.push(id) }), ['BotMark']) assert.equal(row.props['data-flybot'], 'face-person') - assert.equal(find(row, n => String(n.props?.className ?? '').split(' ').includes('bt-person-mark')).length, 1, 'a person mark, not a Bot avatar') + assert.equal(find(row, n => typeof n.props?.['data-shape'] === 'string').length, 2, 'each Bot the person brought, not one shared initial') + assert.equal(find(row, n => n.props?.['data-initial']).length, 0) const words = node => text(find(node, n => n.props?.className === 'bt-face-person-text')[0]) assert.match(words(row), /^SelfA0056Chief, Researcher$/) assert.equal(find(row, n => n.type === 'button').length, 0, "another person's Bots are not my chats") - const mine = expand(FacePersonRow({ person: people[0], onOpenBot: id => opened.push(id) }), ['PersonMark']) + const mine = expand(FacePersonRow({ person: people[0], roster, onOpenBot: id => opened.push(id) }), ['BotMark']) assert.equal(mine.props['data-mine'], true) assert.match(words(mine), /^SelfB0056 \(you\)Chief$/) find(mine, n => n.type === 'button')[0].props.onClick() diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d51cdac8..ebea9095 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -181,7 +181,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). - A person's line keeps its line breaks (#455): `.bt-person-msg .bt-bubble :is(p,li)` is `white-space: pre-line`, as the sender's own `pre-wrap` bubble, since Markdown would join single newlines. A trigger whose source has `shown: true` (a line a running round already drew, host #455) draws nothing. Covered by `core/test/face-people.test.js`. -- A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members · N** count people and draw one row per person (`FacePersonRow`: person mark, name, then that person's Bots; my Bots open their chat). +- A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members · N** count people and draw one row per person (`FacePersonRow`: each Bot that person brought, drawn with the look the chat and the sidebar use — the local roster record, or the friend's seat/chat look, stepped off a same-named local Bot — then the person's name and the Bot names; my Bots open their chat). A row with no Bot still draws the person silhouette, not a blank disc (#1089). Owner names that share an initial (`SelfA1025`, `SelfB1025`) are not the mark (#1104). - In a face room a bubble names its speaker the same way (#457): my Bots' lines `Chief (SelfB)` (`cells.js` `GroupMessage` via `faceBotLabel`), a friend's Bot `Researcher (SelfA)` (`remoteSpeaker`), name only, with the handle as the tooltip instead of a globe and handle line. My own typed line is mine, not my Bot's: it carries my person mark and name (`data-face-mine`) and is never drawn held, so it does not change colour while a Bot's reply waits (`user-message.js`). A `@Chief (SelfA)` chip stays bound to that Bot after it leaves: `faceMentionables` also matches the left seats (after the live ones), so the name never falls through to my own Chief. - **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). For a friend's chat in the roster, `friendBotTitle(bot)` is that one call (#498), on both homes: the sidebar row (one string, no muted owner part), chat header, tab title, composer placeholder, row tooltip and the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row); in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` give the same format (`Chief (Bob)`, my own Bot `Chief (SelfB)` with my name, #457). The social plugin's classic script reads the same functions from `globalThis.__flybot__.names` (`friendBotTitle`, `friendBotOwner`, set in `flybot-slots.js` `registry()`). Approval cards name the receiving person instead (`Chief → SelfB1233`, #418). `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). - **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 1ee66d71..8ff1feb7 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5005,6 +5005,41 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react5.createElement)(CheckIcon)) + ); + })) ++ ); ++} ++function QuestionCard({ matched }) { ++ const pending = matched; ++ const questions = pending.questions ?? []; ++ const [index, setIndex] = (0, import_react5.useState)(0); ++ const [answers, setAnswers] = (0, import_react5.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_react5.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++} ++function PendingQuestion({ sessionId, question, actions, inline }) { ++ const [answered, setAnswered] = (0, import_react5.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_react5.createElement)( ++ "div", ++ { className: inline ? "bt-q-inline" : "bt-q-dock" }, ++ (0, import_react5.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + ); } -@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} @@ -5121,41 +5156,6 @@ diff --git a/client.js b/client.js - style.textContent = text; - document.head.appendChild(style); - return style; -+function QuestionCard({ matched }) { -+ const pending = matched; -+ const questions = pending.questions ?? []; -+ const [index, setIndex] = (0, import_react5.useState)(0); -+ const [answers, setAnswers] = (0, import_react5.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_react5.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); -+} -+function PendingQuestion({ sessionId, question, actions, inline }) { -+ const [answered, setAnswered] = (0, import_react5.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_react5.createElement)( -+ "div", -+ { className: inline ? "bt-q-inline" : "bt-q-dock" }, -+ (0, import_react5.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -+ ); -+} +function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { + const question = useRoster((value) => value.questions?.[sessionId]); + const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; @@ -5320,20 +5320,7 @@ diff --git a/client.js b/client.js + filling ? (0, import_react6.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react6.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + ) + ); - } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -- } -- }; -- try { -- visit(sheet.cssRules); -- } catch { ++} +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 }); @@ -5353,7 +5340,20 @@ diff --git a/client.js b/client.js + canceled ? (0, import_react6.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react6.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react6.createElement)(CheckIcon)) + ) + ); -+} + } +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); +- } +- }; +- try { +- visit(sheet.cssRules); +- } catch { +function RequestView({ bot, request, roster, actions }) { + return request.status === "pending" ? (0, import_react6.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react6.createElement)(SettledSecret, { bot, request, roster }); +} @@ -5456,10 +5456,13 @@ diff --git a/client.js b/client.js - const schedule = () => { - clearTimeout(timer); - timer = setTimeout(render, 120); -- }; ++ }); + }; - const onLoad = (event) => { - if (event.target instanceof HTMLLinkElement) schedule(); -+ }); ++ const edit = (next) => { ++ setValue(next); ++ setReplaced(false); }; - const observer = new MutationObserver(schedule); - observer.observe(document.head, { childList: true }); @@ -5469,10 +5472,6 @@ diff --git a/client.js b/client.js - document.removeEventListener("load", onLoad, true); - clearTimeout(timer); - style.remove(); -+ const edit = (next) => { -+ setValue(next); -+ setReplaced(false); - }; + return (0, import_react6.createElement)( + "div", + { className: "bt-secret-edit-block" }, @@ -5485,44 +5484,7 @@ diff --git a/client.js b/client.js + ), + value !== "" && !long ? (0, import_react6.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react6.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react6.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null + ); - } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); ++} +function SecretRow({ secret, roster, actions, run, open, onToggle }) { + const [confirm, setConfirm] = (0, import_react6.useState)(false); + (0, import_react6.useEffect)(() => { @@ -5565,32 +5527,7 @@ diff --git a/client.js b/client.js + ) + ) : null + ); - } --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 ++} +function AddSecret({ actions, run, onDone }) { + const [name, setName] = (0, import_react6.useState)(""); + const [purpose, setPurpose] = (0, import_react6.useState)(""); @@ -5605,15 +5542,6 @@ diff --git a/client.js b/client.js + } + }); }; -- 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" }) + return (0, import_react6.createElement)( + "div", + { className: "bt-set-row bt-set-paste bt-secret-add" }, @@ -5627,17 +5555,8 @@ diff --git a/client.js b/client.js + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + ) - ); - } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); --} --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) -- ); ++ ); ++} +function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = (0, import_react6.useState)(null); + const [adding, setAdding] = (0, import_react6.useState)(false); @@ -5662,15 +5581,6 @@ diff --git a/client.js b/client.js + adding ? null : (0, import_react6.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react6.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) + ]; } --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; +var SECRET_CSS = ` +.bt-secret-head{align-items:center;gap:10px} +.bt-secret-who{position:relative;flex:none;display:inline-flex} @@ -5734,7 +5644,41 @@ diff --git a/client.js b/client.js +.bt-secret-add .bt-input{height:34px} +@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +`; -+ + +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +- }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); +- }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); +- return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { + const records = (room2.presentedParticipants ?? room2.participants ?? []).filter((p) => p.kind !== "human"); @@ -5747,18 +5691,55 @@ diff --git a/client.js b/client.js + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) -+ }; -+ }; -+ const seats = records.filter((p) => p.leftSeq === void 0).map(seat); -+ for (const id of room2.members ?? []) { -+ const bot = roster.byId[id]; -+ if (!bot || seats.some((item) => item.local?.id === id)) continue; -+ seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); -+ } -+ if (!left) return seats; -+ const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); -+ return [...seats, ...gone]; -+} + }; +- }, [enabled]); +-} +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 + }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +-} +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +- ); ++ const seats = records.filter((p) => p.leftSeq === void 0).map(seat); ++ for (const id of room2.members ?? []) { ++ const bot = roster.byId[id]; ++ if (!bot || seats.some((item) => item.local?.id === id)) continue; ++ seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); ++ } ++ if (!left) return seats; ++ const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); ++ return [...seats, ...gone]; ++} +function faceSeatLabel(seat) { + if (!seat.person) return seat.bot; + return `${seat.bot} (${seat.person})`; @@ -5785,40 +5766,21 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; ++ } + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; } --function 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 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 facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; } --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +-function 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" }) + +// src/client/held-reply.js +var import_react7 = require("react"); @@ -5828,21 +5790,7 @@ diff --git a/client.js b/client.js + constructor(props) { + super(props); + this.state = { failed: false }; - } -- 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 ++ } + static getDerivedStateFromError() { + return { failed: true }; + } @@ -5891,61 +5839,8 @@ diff --git a/client.js b/client.js + // different field elsewhere. A failure that can never succeed draws no button on any surface. + failed && canRetry(held) ? (0, import_react7.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react7.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : null - ); - } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); --} -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function 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" }) -- ); --} --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); --} --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); --} --function 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 })) ++ ); ++ } + const plain = (0, import_react7.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, @@ -5958,39 +5853,29 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy, onClick: () => decide("approve") }, t("Approve")) + ), + error ? (0, import_react7.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : 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" }) ++ ); + return (0, import_react7.createElement)( + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react7.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react7.createElement)(custom.component, { held, decide, call: call3, actions })) : plain ); } --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 useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { @@ -5998,31 +5883,18 @@ diff --git a/client.js b/client.js + if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; + return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); } --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 BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +var 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(); @@ -6074,30 +5946,43 @@ diff --git a/client.js b/client.js + }) ); } --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; +-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}`); +var WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function useDismiss(open, close, refs) { @@ -6107,25 +5992,15 @@ diff --git a/client.js b/client.js + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); + }; - 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(); ++ const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); } - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) ++ }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { @@ -6133,19 +6008,7 @@ diff --git a/client.js b/client.js + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); - } --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]); ++} +function MessageTools({ text, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react8.useState)(null); + const [place, setPlace] = (0, import_react8.useState)(null); @@ -6157,94 +6020,19 @@ diff --git a/client.js b/client.js + const open = (kind, event) => { + if (panel === kind) { + close(); - return; - } -- onAnswer({ selected: [label] }); ++ return; ++ } + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); + setPanel(kind); - }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); ++ }; + const copy2 = () => { + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); - }; -- 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 -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( ++ }; + const download2 = () => { + const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); + const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); @@ -6262,19 +6050,7 @@ diff --git a/client.js b/client.js + sessionId ? (0, import_react8.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react8.createElement)(ReplyIcon)) : null, + (0, import_react8.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react8.createElement)(CheckIcon) : (0, import_react8.createElement)(DotsIcon)), + panel === "react" ? (0, import_react_dom.createPortal)((0, import_react8.createElement)( - "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( ++ "div", + { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, + REACTIONS.map((emoji) => (0, import_react8.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { + close(); @@ -6282,24 +6058,29 @@ diff --git a/client.js b/client.js + } }, emoji)) + ), document.body) : null, + panel === "more" ? (0, import_react_dom.createPortal)((0, import_react8.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 ++ "div", + { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null ); } --function 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)( +- +-// 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" }) +function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react8.createElement)( @@ -6307,8 +6088,10 @@ diff --git a/client.js b/client.js + { className: "bt-think" }, + (0, import_react8.createElement)("summary", null, (0, import_react8.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react8.createElement)("div", { className: "bt-think-text" }, text) -+ ); -+} + ); + } +-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; @@ -6318,18 +6101,36 @@ 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((text) => /^Not sent:/.test(text.trim()))) return void 0; + return (0, import_react8.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react8.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; @@ -6367,8 +6168,27 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react8.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react8.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); @@ -6378,8 +6198,33 @@ 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_react8.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : 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 }) { + const lead = (0, import_react8.createElement)("span", { className: "bt-lead", title: name }, (0, import_react8.createElement)(PersonMark, { name })); + return (0, import_react8.createElement)( @@ -6387,18 +6232,112 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react8.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); -+} + ); + } +-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_react8.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-event" }, + (0, import_react8.createElement)( + "button", @@ -6406,10 +6345,20 @@ diff --git a/client.js b/client.js + (0, import_react8.createElement)("span", null, prefix), + (0, import_react8.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react8.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) +- )); } -- return (0, import_react4.createElement)( +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( + const parts = []; + parts.push((0, import_react8.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { @@ -6451,12 +6400,47 @@ diff --git a/client.js b/client.js + const held = heldLineState(room2, { seq, botId: bot.id, text }); + if (held === "rejected") { + return (0, import_react8.createElement)( -+ "div", + "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")) + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), + (0, import_react8.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, text, bot)) return null; + const lead = (0, import_react8.createElement)( + "button", @@ -6470,54 +6454,18 @@ diff --git a/client.js b/client.js + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react8.createElement)( - "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "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" }, + name, + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ); } --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 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 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)( +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6558,7 +6506,14 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react8.createElement)( -+ "div", + "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")) +- ) + { 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_react8.createElement)( @@ -6569,14 +6524,25 @@ diff --git a/client.js b/client.js + ), + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react8.createElement)("span", { className: "bt-lead", title: person }, (0, import_react8.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+ } + ); + } +- return (0, import_react4.createElement)( + const mark = (0, import_react8.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react8.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react8.createElement)("span", { className: "bt-lead", title: handle }, mark); + return (0, import_react8.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 }) +- { 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)) +- ); +- })) + { 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_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( @@ -6589,17 +6555,107 @@ diff --git a/client.js b/client.js + files.length ? (0, import_react8.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 FirstContact({ source, roster, sessionId }) { ++ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; ++ const who = person || remoteSpeaker(source, roster).name; ++ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); ++ return (0, import_react8.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react8.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); ++} ++function GlobeMark() { ++ return (0, import_react8.createElement)( ++ "svg", ++ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), ++ (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ ); ++} ++function RemoteFiles({ files, plugin, botId }) { ++ const [busy, setBusy] = (0, import_react8.useState)(""); ++ const [error, setError] = (0, import_react8.useState)(""); ++ const download2 = async (file) => { ++ setBusy(file.id); ++ setError(""); ++ try { ++ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); ++ const value = got?.value ?? got; ++ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); ++ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); ++ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); ++ link.click(); ++ setTimeout(() => URL.revokeObjectURL(url), 1e4); ++ } catch (reason) { ++ setError(String(reason?.message ?? reason)); ++ } finally { ++ setBusy(""); + } +- pending.answer({ answers: next }); + }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-file-chips" }, ++ files.map((file) => (0, import_react8.createElement)( ++ "button", ++ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, ++ `📎 ${file.name} · ${fileSize(file.size)}` ++ )), ++ error ? (0, import_react8.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 GroupEvent({ prefix, room: room2, name, roster, actions }) { ++ return (0, import_react8.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 }) ++ { className: "bt-event" }, ++ (0, import_react8.createElement)("span", null, prefix), ++ room2 ? (0, import_react8.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react8.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 FirstContact({ source, roster, sessionId }) { -+ const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; -+ const who = person || remoteSpeaker(source, roster).name; -+ const bot = roster.byId[sessionId]?.name ?? t("your Bot"); -+ return (0, import_react8.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react8.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); ++function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { ++ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; ++ const self = roster.byId[sessionId]; ++ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); ++ const other = team || !scope ? void 0 : findBot(scope); ++ const target = other && other.id !== self?.id ? other : self; ++ if (!target) return null; ++ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); ++ return (0, import_react8.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) ++ ); } -function AnsweredCell({ node }) { - const data = node.data ?? {}; @@ -6613,13 +6669,8 @@ diff --git a/client.js b/client.js - question, - answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } - }))); -+function GlobeMark() { -+ return (0, import_react8.createElement)( -+ "svg", -+ { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), -+ (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ); ++function PartLine({ time }) { ++ return (0, import_react8.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react8.createElement)("span", null, dayLabel(time)) : null); } - -// src/client/secret-card.js @@ -6650,7 +6701,12 @@ diff --git a/client.js b/client.js - 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; ++ } ++ 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", { @@ -6674,113 +6730,6 @@ diff --git a/client.js b/client.js - if (event.key !== "Enter" || event.nativeEvent.isComposing) return; - event.preventDefault(); - onEnter?.(); -+function RemoteFiles({ files, plugin, botId }) { -+ const [busy, setBusy] = (0, import_react8.useState)(""); -+ const [error, setError] = (0, import_react8.useState)(""); -+ const download2 = async (file) => { -+ setBusy(file.id); -+ setError(""); -+ try { -+ const got = await call(`flybot/${plugin}/attachment`, { id: file.id, botId }); -+ const value = got?.value ?? got; -+ const bytes = Uint8Array.from(atob(value.content_base64), (c) => c.charCodeAt(0)); -+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" })); -+ const link = Object.assign(document.createElement("a"), { href: url, download: value.name ?? file.name }); -+ link.click(); -+ setTimeout(() => URL.revokeObjectURL(url), 1e4); -+ } catch (reason) { -+ setError(String(reason?.message ?? reason)); -+ } finally { -+ setBusy(""); - } -- }); -+ }; -+ return (0, import_react8.createElement)( -+ "div", -+ { className: "bt-file-chips" }, -+ files.map((file) => (0, import_react8.createElement)( -+ "button", -+ { key: file.id, type: "button", className: "bt-file-chip", disabled: busy === file.id, title: `Download ${file.name} (untrusted file from a remote Bot)`, onClick: () => download2(file) }, -+ `📎 ${file.name} · ${fileSize(file.size)}` -+ )), -+ error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); - } --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { -+ return (0, import_react8.createElement)( - "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -+ { className: "bt-event" }, -+ (0, import_react8.createElement)("span", null, prefix), -+ room2 ? (0, import_react8.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react8.createElement)("span", null, name) : null - ); - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -+function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -+ const self = roster.byId[sessionId]; -+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -+ const other = team || !scope ? void 0 : findBot(scope); -+ const target = other && other.id !== self?.id ? other : self; -+ if (!target) return null; -+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -+ return (0, import_react8.createElement)( - "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null -+ { className: "bt-event" }, -+ (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) - ); - } --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)); -+function PartLine({ time }) { -+ return (0, import_react8.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react8.createElement)("span", null, dayLabel(time)) : null); -+} -+var scrollParent = (node) => { -+ for (let at = node.parentElement; at; at = at.parentElement) { -+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -+ } -+ return null; -+}; +function EarlierParts({ sessionId, roster, actions }) { + const [view, setView] = (0, import_react8.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react8.useRef)(null); @@ -6801,10 +6750,7 @@ diff --git a/client.js b/client.js + busy.current = false; + if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); + }); - }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( ++ }; + (0, import_react8.useEffect)(() => { + alive.current = true; + load(void 0); @@ -6834,43 +6780,13 @@ diff --git a/client.js b/client.js + else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); + } + return (0, import_react8.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-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react8.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 }) : "" ++ "div", + { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react8.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null - ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) ++ ), + rows.map((row, index) => { + const key = rows.length - index; + switch (row.kind) { @@ -6900,27 +6816,8 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react8.createElement)(PartLine, { time: view.startedAt }) - ); - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) -- ) -- ); ++ ); ++} +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6992,7 +6889,8 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); -+ } + } +- }); + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react8.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); @@ -7001,19 +6899,37 @@ diff --git a/client.js b/client.js + return (0, import_react8.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } } --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 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_react8.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react8.createElement)("span", null, t("New"))); } --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 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_react8.useEffect)(() => { @@ -7031,20 +6947,87 @@ 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_react8.createElement)(NewDivider) : 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; ++ return null; } --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 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)) +- ) +- ); +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); @@ -7066,6 +7049,46 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react8.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 }); ++var SEPARATOR_GAP_MS = 30 * 60 * 1e3; ++function clockLabel(time, now = Date.now()) { ++ const date = new Date(time); ++ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); ++ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); ++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); ++ if (days === 0) return t("Today {time}", { time: clock4 }); ++ if (days === 1) return t("Yesterday {time}", { time: clock4 }); ++ const sameYear = date.getFullYear() === new Date(now).getFullYear(); ++ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; + } +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); ++function nextTurnStart(turns, turn) { ++ let next; ++ for (const key in turns) { ++ const candidate = Number(key); ++ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; + } +- return null; +-} +-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 }))); ++ return next === void 0 ? void 0 : turns[next].first; + } -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); @@ -7098,16 +7121,14 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) - ) - ); -+var SEPARATOR_GAP_MS = 30 * 60 * 1e3; -+function clockLabel(time, now = Date.now()) { -+ const date = new Date(time); -+ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -+ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -+ if (days === 0) return t("Today {time}", { time: clock4 }); -+ if (days === 1) return t("Yesterday {time}", { time: clock4 }); -+ const sameYear = date.getFullYear() === new Date(now).getFullYear(); -+ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; } -function ValueEditor({ secret, onSave }) { - const [value, setValue] = (0, import_react5.useState)(""); @@ -7138,13 +7159,14 @@ diff --git a/client.js b/client.js - ), - 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 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; ++var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; ++function movedOnSince(visit, turns, hiddenTurns) { ++ if (typeof visit?.entered !== "number") return false; ++ for (const key in turns ?? {}) { ++ if (hiddenTurns?.includes(Number(key))) continue; ++ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } -+ return next === void 0 ? void 0 : turns[next].first; ++ return false; } -function SecretRow({ secret, roster, actions, run, open, onToggle }) { - const [confirm, setConfirm] = (0, import_react5.useState)(false); @@ -7187,24 +7209,6 @@ diff --git a/client.js b/client.js - ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) - ) - ) : null -+function openingTurn(turns, firstTurns, hiddenTurns) { -+ if (!turns || !firstTurns) return null; -+ const start = nextTurnStart(turns, -Infinity); -+ if (start === void 0) return null; -+ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -+ const listed = firstTurns.split(",").map(Number); -+ const earlier = listed.filter((entry) => entry < turn); -+ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+} -+var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; -+function movedOnSince(visit, turns, hiddenTurns) { -+ if (typeof visit?.entered !== "number") return false; -+ for (const key in turns ?? {}) { -+ if (hiddenTurns?.includes(Number(key))) continue; -+ if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; -+ } -+ return false; -+} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -7434,52 +7438,9 @@ diff --git a/client.js b/client.js - }; - 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 -- ); -- }) -- ); +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; - } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); ++} +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react8.useEffect)(() => { @@ -7503,33 +7464,14 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); - } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); ++ } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); + }; + void tick(); - return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); ++ return () => { + stop = true; + clearTimeout(timer); - }; -- }, [open]); --} --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; ++ }; + }, [done, sessionId, callId]); + let replies = live.replies; + if (done) { @@ -7537,60 +7479,22 @@ diff --git a/client.js b/client.js + replies = JSON.parse(contentText(content)).replies ?? []; + } catch { + replies = []; - } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -- }); -- }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); -- }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( ++ } + } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react8.createElement)( "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( -- "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null -- ); --} --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7638,7 +7542,24 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "approval" || reply.kind === "working") { + return (0, import_react8.createElement)( -+ "div", + "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 +- ); + { key: `${reply.botId}-${index}`, className: "bt-event" }, + (0, import_react8.createElement)( + "button", @@ -7669,18 +7590,60 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); -+ }) + }) ); } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); +-} +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); +- return; +- } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +- }); +- }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); +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) { @@ -7692,43 +7655,41 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); -+ }; + }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null +- ); + visit(root); + return calls; } --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; +-function ThinkingRow({ text }) { - return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -- }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) - ); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); @@ -7739,19 +7700,15 @@ diff --git a/client.js b/client.js + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7827,7 +7784,71 @@ diff --git a/client.js b/client.js + return done && !call4.isError ? (0, import_react8.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; - } ++ } + } +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { ++function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null +- ); ++ const root = node.data.root; ++ const turn = node.location?.turn?.turn ?? root.turn; ++ const hidden = isHiddenTurn(roster, sessionId, turn); ++ 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_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); + } +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } - const parts = []; - parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); - bots.forEach((bot, index) => { @@ -7840,6 +7861,13 @@ diff --git a/client.js b/client.js - )); - }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ ++// src/client/awaiting.js ++var AWAIT_REPLY_MS = 10 * 60 * 1e3; ++function awaitingReply(roster, sessionId, now = Date.now()) { ++ const at = roster?.remoteAwaiting?.[sessionId]; ++ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; ++ return now - at < AWAIT_REPLY_MS; } -function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { - const lead = (0, import_react6.createElement)( @@ -7858,37 +7886,6 @@ diff --git a/client.js b/client.js - ), - (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) - ); -+function ToolCell({ node, sessionId, useRoster, useUi, actions }) { -+ const roster = useRoster((value) => value); -+ const root = node.data.root; -+ const turn = node.location?.turn?.turn ?? root.turn; -+ const hidden = isHiddenTurn(roster, sessionId, turn); -+ const 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_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); - } --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 -- ); -+ -+// src/client/awaiting.js -+var AWAIT_REPLY_MS = 10 * 60 * 1e3; -+function awaitingReply(roster, sessionId, now = Date.now()) { -+ const at = roster?.remoteAwaiting?.[sessionId]; -+ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; -+ return now - at < AWAIT_REPLY_MS; -+} + +// src/client/remote-approval-wait.js +var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; @@ -7896,20 +7893,7 @@ diff --git a/client.js b/client.js + if (!remote) return null; + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; - } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) -- ); ++} + +// src/client/activity-row.js +var import_react9 = require("react"); @@ -7922,8 +7906,13 @@ diff --git a/client.js b/client.js + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } --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); +-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 +- ); +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -7938,11 +7927,19 @@ diff --git a/client.js b/client.js + }, [shown, at]); + return shown; } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) +- ); +function findHost(where) { + if (where !== TAIL_HOST) return document.querySelector(where); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -7956,26 +7953,7 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); ++}; +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react9.useRef)(-1); + const wasSending = (0, import_react9.useRef)(false); @@ -8000,137 +7978,18 @@ diff --git a/client.js b/client.js + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(check); - }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); ++ }); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + check(); - return () => { -- alive.current = false; ++ return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -- } -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -- "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) -- ); ++ }; + }, [remote, sending]); + return replied; } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +-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); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react9.useState)(null); + (0, import_react9.useEffect)(() => { @@ -8155,7 +8014,32 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; -+} + } +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +- } +- return null; +-}; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +function useRowEntrances(replacing) { + const check = (0, import_react9.useRef)(replacing); + check.current = replacing; @@ -8176,18 +8060,13 @@ diff --git a/client.js b/client.js + observer.disconnect(); + column = next; + if (column) observer.observe(column, { childList: true, subtree: true }); - } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true ++ } + if (!column) return; + const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; + let last = -1; + items.forEach((item, index) => { + if (seen.has(item)) last = index; - }); ++ }); + const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); + for (const item of fresh) seen.add(item); + if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; @@ -8200,12 +8079,40 @@ diff --git a/client.js b/client.js + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); -+ }); + }); +- }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); + scan(); -+ return () => { + return () => { +- alive.current = false; + cancelAnimationFrame(frame); + observer.disconnect(); -+ }; + }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); +- } +- return (0, import_react6.createElement)( + }, []); +} +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { @@ -8246,10 +8153,7 @@ diff --git a/client.js b/client.js + if (wait <= 0) { + make(); + return void 0; - } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ } + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -8262,10 +8166,7 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; - } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); -- } ++ } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -8309,7 +8210,43 @@ 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_react9.createElement)( -+ "div", + "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; +- } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) +- ); + { className: "bt-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_react9.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react9.createElement)( @@ -8323,9 +8260,7 @@ diff --git a/client.js b/client.js + ) + ) + ), host); - } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++} + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -8335,24 +8270,7 @@ diff --git a/client.js b/client.js + if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); - } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; ++} + +// src/client/overlay-hooks.js +var import_react10 = require("react"); @@ -8372,28 +8290,41 @@ diff --git a/client.js b/client.js + }; + }, []); } --function Nothing() { -- return null; -+function usePaneLeft() { -+ const [paneLeft, setPaneLeft] = (0, import_react10.useState)(280); -+ (0, import_react10.useLayoutEffect)(() => { -+ const column = document.querySelector('[class*="_footArea"]')?.parentElement; -+ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); -+ }, []); -+ return paneLeft; - } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+ -+// src/client/member-status.js +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++function usePaneLeft() { ++ const [paneLeft, setPaneLeft] = (0, import_react10.useState)(280); ++ (0, import_react10.useLayoutEffect)(() => { ++ const column = document.querySelector('[class*="_footArea"]')?.parentElement; ++ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); ++ }, []); ++ return paneLeft; ++} ++ ++// src/client/member-status.js +var import_react11 = require("react"); +var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; +var ROWS_MAX = 4; @@ -8413,61 +8344,89 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; -+ }); - } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; + }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); +- } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); - } -- return next === void 0 ? void 0 : turns[next].first; + } +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; ++} +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -+} +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; + } +-function Nothing() { +- return null; +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -8476,16 +8435,29 @@ diff --git a/client.js b/client.js + const first = workers[0]; + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; -+} + } +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +function Face({ entry, size }) { + return (0, import_react11.createElement)( + "span", + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react11.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) - ); ++ ); } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; @@ -8497,63 +8469,7 @@ diff --git a/client.js b/client.js + (0, import_react11.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react11.createElement)(Shimmer, null, text)), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null + ); - } --var PHRASES = { -- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] --}; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -- } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -- } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; ++} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -8594,6 +8510,7 @@ diff --git a/client.js b/client.js + rest.length > 0 ? (0, import_react11.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); } +- return next === void 0 ? void 0 : turns[next].first; + return (0, import_react11.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react11.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -8605,26 +8522,257 @@ diff --git a/client.js b/client.js + onOpen: () => actions.openSession(item.room.id) + }))); } +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++function useFlowColumn(ref, active, key) { ++ (0, import_react11.useLayoutEffect)(() => { ++ const node = ref.current; ++ const flow = document.querySelector("[data-chat-flow]"); ++ if (!active || !node || !flow) return void 0; ++ const place = () => { ++ node.style.marginInlineStart = "0px"; ++ const column = flow.getBoundingClientRect(); ++ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; ++ node.style.width = `${Math.round(column.width)}px`; ++ }; ++ place(); ++ const observer = new ResizeObserver(place); ++ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); ++ return () => observer.disconnect(); ++ }, [active, key]); + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +- }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null ++function useNow(active) { ++ const [now, setNow] = (0, import_react11.useState)(Date.now); ++ (0, import_react11.useEffect)(() => { ++ if (!active) return void 0; ++ const timer = setInterval(() => setNow(Date.now()), 3e4); ++ return () => clearInterval(timer); ++ }, [active]); ++ return now; ++} ++var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; ++var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ ++ ["always-bot", "Always allow for this Bot"], ++ ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] ++]; ++function ApprovalCard({ approval, bot, actions }) { ++ const [busy, setBusy] = (0, import_react11.useState)(false); ++ const answer = (choice) => { ++ setBusy(true); ++ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); ++ }; ++ return (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, ++ (0, import_react11.createElement)("div", { className: "bt-mw-tool" }, approval.tool), ++ approval.detail?.text ? (0, import_react11.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, ++ approval.reason ? (0, import_react11.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, ++ approval.detail?.code ? (0, import_react11.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, ++ approval.answerable ? (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-actions" }, ++ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), ++ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), ++ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react11.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) ++ ) : (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-actions" }, ++ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) ++ ) ++ ); ++} ++function MemberWork({ entry, bot, room: room2, actions, now }) { ++ const [stopping, setStopping] = (0, import_react11.useState)(false); ++ if (entry.state === "ask") { ++ return (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-member" }, ++ (0, import_react11.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_react11.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) ++ ); ++ } ++ if (entry.state !== "work") return null; ++ const stop = () => { ++ setStopping(true); ++ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); ++ }; ++ return (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-member" }, ++ (0, import_react11.createElement)( ++ "div", ++ { className: "bt-mw-state bt-mw-work" }, ++ (0, import_react11.createElement)("span", null, (0, import_react11.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), ++ entry.here && entry.doing.handed !== true ? (0, import_react11.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null ++ ) + ); + } +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); ++function useMemberWork(room2, useWork) { ++ const work = useWork?.((value) => value) ?? NO_WORK; ++ const entries2 = room2 ? memberWork(room2, work) : []; ++ const now = useNow(entries2.some((entry) => entry.state !== "idle")); ++ return { entries: entries2, now }; + } +-var 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 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} ++.bt-mw-row:hover{background:var(--bt-hover)} ++.bt-mw-row>.bt-mark{flex:none} ++.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} ++.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} ++.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} ++.bt-mw-stop:hover{background:var(--bt-hover)} ++.bt-mw-stop:disabled{opacity:.5;cursor:default} ++.bt-mw-line{gap:8px} ++.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} ++.bt-mw-stack>*+*{margin-left:-4px} ++.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} ++.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} ++.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} ++.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} ++body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} ++.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} ++.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} ++.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} ++.bt-mw-tool{font-weight:600;color:var(--bt-ink)} ++.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} ++.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} ++.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} ++.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} ++.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} ++.bt-mw-actions>button:disabled{opacity:.5;cursor:default} ++@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} ++@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} ++`; ++ ++// src/client/inbox.js ++var import_react12 = require("react"); ++var KEEP_MS = 7 * 864e5; ++var POLL_MS2 = 3e4; ++var DONE_MAX = 20; ++var FEEDBACK_MS = 5e3; ++var line = (d) => (0, import_react12.createElement)("path", { d }); ++var icon = (...paths) => function StatusIcon() { ++ return (0, import_react12.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); + }; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] ++var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; ++var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); ++var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); ++var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); ++var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); ++var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); ++var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); ++var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); ++var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); ++var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); ++var STATUSES = { ++ approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, ++ held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, ++ input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, ++ result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, ++ failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, ++ running: { label: "Working", tone: "run", Icon: RunIcon }, ++ task: { label: "In progress", tone: "run", Icon: RunIcon }, ++ done: { label: "Done", tone: "done", Icon: DoneIcon }, ++ cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } ++}; ++var GROUPS = [ ++ { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, ++ { id: "progress", title: "In progress", empty: "Nothing is running." }, ++ { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } + ]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +- } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +- } +-} -function workPhrase(tool) { - if (!tool) return t("Thinking about it"); - return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); -+function useFlowColumn(ref, active, key) { -+ (0, import_react11.useLayoutEffect)(() => { -+ const node = ref.current; -+ const flow = document.querySelector("[data-chat-flow]"); -+ if (!active || !node || !flow) return void 0; -+ const place = () => { -+ node.style.marginInlineStart = "0px"; -+ const column = flow.getBoundingClientRect(); -+ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; -+ node.style.width = `${Math.round(column.width)}px`; -+ }; -+ place(); -+ const observer = new ResizeObserver(place); -+ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); -+ return () => observer.disconnect(); -+ }, [active, key]); - } +-} -function useLiveNote(actions, sessionId, id, entry) { - const key = entry ? JSON.stringify(entry) : ""; - (0, import_react6.useEffect)(() => { @@ -8632,21 +8780,24 @@ diff --git a/client.js b/client.js - actions.noteLive(sessionId, id, JSON.parse(key)); - return () => actions.noteLive(sessionId, id, null); - }, [sessionId, id, key]); -+function useNow(active) { -+ const [now, setNow] = (0, import_react11.useState)(Date.now); -+ (0, import_react11.useEffect)(() => { -+ if (!active) return void 0; -+ const timer = setInterval(() => setNow(Date.now()), 3e4); -+ return () => clearInterval(timer); -+ }, [active]); -+ return now; - } +-} -function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { - const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); - (0, import_react6.useEffect)(() => { - if (done) return void 0; - let stop = false; -- let timer; ++var sharedTasks = []; ++var taskListeners = /* @__PURE__ */ new Set(); ++function publishTasks(list) { ++ sharedTasks = Array.isArray(list) ? list : []; ++ for (const listener of taskListeners) listener(sharedTasks); ++} ++function useTasks(actions) { ++ const [tasks, setTasks] = (0, import_react12.useState)(sharedTasks); ++ (0, import_react12.useEffect)(() => { ++ taskListeners.add(setTasks); ++ let stopped = false; + let timer; - let seen = ""; - const tick = async () => { - const result = await actions.roomProgress(sessionId); @@ -8660,10 +8811,19 @@ diff --git a/client.js b/client.js - timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; - void tick(); -- return () => { ++ const load = () => Promise.resolve(actions?.tasks?.()).then((list) => { ++ if (!stopped && list !== void 0) publishTasks(list); ++ }).catch(() => { ++ }).finally(() => { ++ if (!stopped) timer = setTimeout(load, POLL_MS2); ++ }); ++ load(); + return () => { - stop = true; -- clearTimeout(timer); -- }; ++ stopped = true; + clearTimeout(timer); ++ taskListeners.delete(setTasks); + }; - }, [done, sessionId, callId]); - let replies = live.replies; - if (done) { @@ -8678,19 +8838,7 @@ diff --git a/client.js b/client.js - if (replies.length === 0 && pending.length === 0) return null; - const room2 = roster.roomsById[sessionId]; - return (0, import_react6.createElement)( -+var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; -+var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ -+ ["always-bot", "Always allow for this Bot"], -+ ...approval.roomId ? [["always-room", "Always allow for this group"]] : [] -+]; -+function ApprovalCard({ approval, bot, actions }) { -+ const [busy, setBusy] = (0, import_react11.useState)(false); -+ const answer = (choice) => { -+ setBusy(true); -+ void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); -+ }; -+ return (0, import_react11.createElement)( - "div", +- "div", - { className: "bt-group-replies" }, - replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; @@ -8751,24 +8899,8 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) - ); - }) -+ { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, -+ (0, import_react11.createElement)("div", { className: "bt-mw-tool" }, approval.tool), -+ approval.detail?.text ? (0, import_react11.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, -+ approval.reason ? (0, import_react11.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, -+ approval.detail?.code ? (0, import_react11.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, -+ approval.answerable ? (0, import_react11.createElement)( -+ "div", -+ { className: "bt-mw-actions" }, -+ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), -+ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), -+ ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react11.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) -+ ) : (0, import_react11.createElement)( -+ "div", -+ { className: "bt-mw-actions" }, -+ (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) -+ ) - ); - } +- ); +-} -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) { @@ -8780,34 +8912,10 @@ diff --git a/client.js b/client.js - seen.add(id); - calls.push(call2); - for (const child of call2.subCalls ?? []) visit(child); -+function MemberWork({ entry, bot, room: room2, actions, now }) { -+ const [stopping, setStopping] = (0, import_react11.useState)(false); -+ if (entry.state === "ask") { -+ return (0, import_react11.createElement)( -+ "div", -+ { className: "bt-mw-member" }, -+ (0, import_react11.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_react11.createElement)(ApprovalCard, { key: approval.id, approval, bot, actions })) -+ ); -+ } -+ if (entry.state !== "work") return null; -+ const stop = () => { -+ setStopping(true); -+ void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; +- }; - visit(root); - return calls; -+ return (0, import_react11.createElement)( -+ "div", -+ { className: "bt-mw-member" }, -+ (0, import_react11.createElement)( -+ "div", -+ { className: "bt-mw-state bt-mw-work" }, -+ (0, import_react11.createElement)("span", null, (0, import_react11.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), -+ entry.here && entry.doing.handed !== true ? (0, import_react11.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null -+ ) -+ ); - } +-} -function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); - (0, import_react6.useLayoutEffect)(() => { @@ -8816,11 +8924,8 @@ diff --git a/client.js b/client.js - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); -+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 }; ++ }, []); ++ return tasks; } -function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { - const tool = toolName(call2); @@ -8891,102 +8996,6 @@ diff --git a/client.js b/client.js - if (!done) return null; - const room2 = roster.rooms.find((entry) => entry.name === arg("group")); - return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; -+var 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} -+.bt-mw-row:hover{background:var(--bt-hover)} -+.bt-mw-row>.bt-mark{flex:none} -+.bt-mw-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-mw-row-time{flex:none;font-size:12px;color:var(--bt-ink-3);white-space:nowrap} -+.bt-mw-work{display:flex;align-items:center;justify-content:space-between;gap:8px} -+.bt-mw-stop{flex:none;height:22px;padding:0 10px;border:1px solid var(--bt-line,rgba(0,0,0,.12));border-radius:11px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer} -+.bt-mw-stop:hover{background:var(--bt-hover)} -+.bt-mw-stop:disabled{opacity:.5;cursor:default} -+.bt-mw-line{gap:8px} -+.bt-mw-stack{display:inline-flex;align-items:center;flex:none;line-height:0} -+.bt-mw-stack>*+*{margin-left:-4px} -+.bt-mw-face{position:relative;display:inline-flex;flex:none;border-radius:50%} -+.bt-mw-stack .bt-mw-face{box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -+.bt-mw-face[data-ask]::after{content:"";position:absolute;right:-2px;bottom:-2px;width:7px;height:7px;border-radius:50%;background:#e3a008;box-shadow:0 0 0 1.5px var(--bt-main,#fff)} -+.bt-mw-ask{color:#b7791f;flex:none;white-space:nowrap} -+body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} -+.bt-mw-member{padding:0 6px 8px 42px;display:flex;flex-direction:column;gap:6px} -+.bt-mw-state{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:-4px} -+.bt-mw-card{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:12px;line-height:17px;color:var(--bt-ink-2)} -+.bt-mw-tool{font-weight:600;color:var(--bt-ink)} -+.bt-mw-text{color:var(--bt-ink);overflow-wrap:anywhere} -+.bt-mw-reason{overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} -+.bt-mw-code{display:block;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -+.bt-mw-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px;margin-top:4px} -+.bt-mw-actions>button{height:28px;padding:0 12px;font-size:12px} -+.bt-mw-actions>button:disabled{opacity:.5;cursor:default} -+@keyframes bt-mw-in{from{opacity:0;transform:translateY(4px)}} -+@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} -+`; -+ -+// src/client/inbox.js -+var import_react12 = require("react"); -+var KEEP_MS = 7 * 864e5; -+var POLL_MS2 = 3e4; -+var DONE_MAX = 20; -+var FEEDBACK_MS = 5e3; -+var line = (d) => (0, import_react12.createElement)("path", { d }); -+var icon = (...paths) => function StatusIcon() { -+ return (0, import_react12.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); -+}; -+var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; -+var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); -+var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); -+var HeldIcon = icon(RING, "M10 6.5V10l2.25 1.5"); -+var QuestionIcon = icon(RING, "M8.1 8a2 2 0 1 1 2.65 1.9c-.45.18-.75.6-.75 1.1v.25", "M10 13.5h.01"); -+var ResultIcon = icon(RING, "M7 10.25l2 2 4-4.25"); -+var FailedIcon = icon(RING, "M7.75 7.75l4.5 4.5M12.25 7.75l-4.5 4.5"); -+var RunIcon = icon(RING, "M8.5 7.25v5.5l4.25-2.75z"); -+var DoneIcon = icon("M4.5 10.5l3.5 3.5 7.5-8"); -+var CancelledIcon = icon(RING, "M5.25 14.75l9.5-9.5"); -+var STATUSES = { -+ approval: { label: "Needs approval", tone: "ask", Icon: AskIcon }, -+ held: { label: "Waiting to send", tone: "ask", Icon: HeldIcon }, -+ input: { label: "Needs your answer", tone: "ask", Icon: QuestionIcon }, -+ result: { label: "Result ready", tone: "ask", Icon: ResultIcon }, -+ failed: { label: "Failed", tone: "danger", Icon: FailedIcon }, -+ running: { label: "Working", tone: "run", Icon: RunIcon }, -+ task: { label: "In progress", tone: "run", Icon: RunIcon }, -+ done: { label: "Done", tone: "done", Icon: DoneIcon }, -+ cancelled: { label: "Cancelled", tone: "muted", Icon: CancelledIcon } -+}; -+var GROUPS = [ -+ { id: "needs", title: "Needs you", empty: "Nothing needs you right now." }, -+ { id: "progress", title: "In progress", empty: "Nothing is running." }, -+ { id: "done", title: "Done", empty: "Nothing finished in the last 7 days." } -+]; -+var sharedTasks = []; -+var taskListeners = /* @__PURE__ */ new Set(); -+function publishTasks(list) { -+ sharedTasks = Array.isArray(list) ? list : []; -+ for (const listener of taskListeners) listener(sharedTasks); -+} -+function useTasks(actions) { -+ const [tasks, setTasks] = (0, import_react12.useState)(sharedTasks); -+ (0, import_react12.useEffect)(() => { -+ taskListeners.add(setTasks); -+ let stopped = false; -+ let timer; -+ const load = () => Promise.resolve(actions?.tasks?.()).then((list) => { -+ if (!stopped && list !== void 0) publishTasks(list); -+ }).catch(() => { -+ }).finally(() => { -+ if (!stopped) timer = setTimeout(load, POLL_MS2); -+ }); -+ load(); -+ return () => { -+ stopped = true; -+ clearTimeout(timer); -+ taskListeners.delete(setTasks); -+ }; -+ }, []); -+ return tasks; -+} +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -11543,11 +11552,10 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6082,271 +6082,47 @@ async function openCapture() { - await new Promise((resolve) => { +@@ -6083,270 +6083,46 @@ async function openCapture() { recorder.onstop = resolve; recorder.stop(); -- }); + }); - stream.getTracks().forEach((track) => track.stop()); - const blob = new Blob(chunks, { type: recorder.mimeType }); - if (blob.size === 0) return null; @@ -11808,7 +11816,6 @@ diff --git a/client.js b/client.js - ) - ) - ); -+ }); + stream.getTracks().forEach((track) => track.stop()); + const blob = new Blob(chunks, { type: recorder.mimeType }); + if (blob.size === 0) return null; @@ -13741,7 +13748,7 @@ diff --git a/client.js b/client.js } }; window.addEventListener("mousedown", onDown); -@@ -7646,514 +7646,863 @@ function MemoryDialog({ bot, actions, leaving = false }) { +@@ -7646,514 +7646,896 @@ function MemoryDialog({ bot, actions, leaving = false }) { window.removeEventListener("mousedown", onDown); window.removeEventListener("keydown", onKey, true); }; @@ -14049,6 +14056,37 @@ diff --git a/client.js b/client.js + ["everyone", "Everyone", "Every member answers in turn, admin first."] +]; +var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone"; ++function faceSeatBot(seat, roster) { ++ if (!seat) return void 0; ++ const record = seat.record && typeof seat.record === "object" ? seat.record : {}; ++ const extra = {}; ++ if (typeof record.color === "string" && record.color) extra.color = record.color; ++ if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar; ++ if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local; ++ const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : ""; ++ return lookBotForRemote({ ++ handle, ++ displayName: record.displayName || seat.bot, ++ name: seat.bot, ++ label: record.label, ++ id: record.id || seat.key, ++ ...extra ++ }, roster); ++} ++function faceMemberLook(bot) { ++ if (!bot) return void 0; ++ return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot); ++} ++function faceMarks(person, roster, size) { ++ const marks = []; ++ for (const seat of person?.seats ?? []) { ++ const bot = faceSeatBot(seat, roster); ++ if (!bot) continue; ++ marks.push((0, import_react26.createElement)(BotMark, { key: seat.key, bot, size, live: false })); ++ } ++ if (marks.length === 0) return [(0, import_react26.createElement)(PersonMark, { key: "person", size, name: person?.name })]; ++ return marks; ++} +function faceRoomCount(room2, roster) { + const people2 = facePeople(room2, roster).length; + const seats = faceSeats(room2, roster).length; @@ -14058,7 +14096,7 @@ diff --git a/client.js b/client.js +} +var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase(); +var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead)); -+function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { ++function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { + const bots = person.seats.flatMap((seat, index) => { + const name = seat.local && onOpenBot ? (0, import_react26.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react26.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); + const marked = isLeadSeat(seat, lead) ? [name, (0, import_react26.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; @@ -14129,7 +14167,7 @@ diff --git a/client.js b/client.js + (0, import_react26.createElement)( + "span", + { className: "bt-gs-who bt-face-person" }, -+ (0, import_react26.createElement)(PersonMark, { size, name: person.name }), ++ (0, import_react26.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), + (0, import_react26.createElement)( + "span", + { className: "bt-face-person-text" }, @@ -14417,7 +14455,7 @@ diff --git a/client.js b/client.js + face ? (0, import_react26.createElement)( + Card, + { title: t("Members · {count}", { count: people2.length }) }, -+ people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle })) ++ people2.map((person) => (0, import_react26.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react26.createElement)( + Card, @@ -14733,6 +14771,8 @@ diff --git a/client.js b/client.js +.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} +.bt-gs-add{gap:8px;color:var(--bt-ink-2)} ++.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px} ++.bt-face-marks>.bt-mark{flex:none} +.bt-face-person{cursor:default} +.bt-face-person:hover .bt-row-title{text-decoration:none} +.bt-face-person-text{display:flex;flex-direction:column;min-width:0} @@ -18606,7 +18646,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,194 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,195 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -18700,6 +18740,7 @@ diff --git a/client.js b/client.js + (0, import_react38.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react38.createElement)(FacePersonRow, { + key: person.key, + person, ++ roster, + className: "bt-member", + onOpenBot: (id) => actions.openSession(id), + lead: room2.face?.lead?.handle diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 4d6188b9..027f3896 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -145,7 +145,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + h(FaceLead, { room, roster, pick }), + h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: people.length })), + h('div', { className: 'bt-members', role: 'list' }, people.map(person => h(FacePersonRow, { -+ key: person.key, person, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle, ++ key: person.key, person, roster, className: 'bt-member', onOpenBot: id => actions.openSession(id), lead: room.face?.lead?.handle, + })))) +} + @@ -297,12 +297,13 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js diff --git a/src/client/group-settings.js b/src/client/group-settings.js --- a/src/client/group-settings.js +++ b/src/client/group-settings.js -@@ -1,11 +1,14 @@ +@@ -1,11 +1,15 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' -import { AdminStar, BotAvatar, BotMark, RoomAvatar } from './mark.js' -+import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookBotForRemote } from './mark.js' ++import { AdminStar, BotAvatar, BotMark, PersonMark, RoomAvatar, lookAvoidingLocals, lookBotForRemote, lookKey, lookOf } from './mark.js' ++import { setLocalBots } from './characters.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' @@ -313,7 +314,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], -@@ -15,9 +15,94 @@ export const ROOM_MODES = [ +@@ -15,9 +15,136 @@ export const ROOM_MODES = [ // Without an admin the host answers as everyone, whatever the stored mode says. export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone') @@ -321,6 +322,48 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js +import { faceSeats, faceSeatLabel, facePeople, facePersonLabel } from './face-seats.js' + +export { faceSeats, faceSeatLabel, facePeople } ++export { setLocalBots, lookKey } ++ ++// The Bot a member row draws. A local seat is the roster record the sidebar uses. A friend's ++// seat is the chat record, with the face-room look winning, the same way a transcript mark is ++// chosen. Two homes named SelfA… and SelfB… share an initial; the row must not. ++export function faceSeatBot(seat, roster) { ++ if (!seat) return undefined ++ const record = seat.record && typeof seat.record === 'object' ? seat.record : {} ++ const extra = {} ++ if (typeof record.color === 'string' && record.color) extra.color = record.color ++ if (record.avatar && typeof record.avatar === 'object') extra.avatar = record.avatar ++ if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local ++ const handle = typeof record.handle === 'string' && record.handle.trim() ++ ? record.handle ++ : (typeof seat.key === 'string' && seat.key.startsWith('@') ? seat.key : '') ++ return lookBotForRemote({ ++ handle, ++ displayName: record.displayName || seat.bot, ++ name: seat.bot, ++ label: record.label, ++ id: record.id || seat.key, ++ ...extra, ++ }, roster) ++} ++ ++// The picture BotMark paints for that Bot: a friend's look steps off a same-named local Bot. ++export function faceMemberLook(bot) { ++ if (!bot) return undefined ++ return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot) ++} ++ ++function faceMarks(person, roster, size) { ++ const marks = [] ++ for (const seat of person?.seats ?? []) { ++ const bot = faceSeatBot(seat, roster) ++ if (!bot) continue ++ marks.push(h(BotMark, { key: seat.key, bot, size, live: false })) ++ } ++ // No seat to draw is the silhouette, never an empty disc (#1089). ++ if (marks.length === 0) return [h(PersonMark, { key: 'person', size, name: person?.name })] ++ return marks ++} + +// A face room says what it counts, people and Bots, not an ambiguous "2 members" (#306). +export function faceRoomCount(room, roster) { @@ -337,7 +380,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + +// One row per person (#457): "Ann (you) · Chief, Researcher", my own Bots each opening its chat. +// `lead`: the room lead's handle (#476), marked on its Bot. -+export function FacePersonRow({ person, onOpenBot, lead, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) { ++export function FacePersonRow({ person, onOpenBot, lead, roster, className = 'bt-set-row bt-bs-row bt-gs-member', size = 26 }) { + const bots = person.seats.flatMap((seat, index) => { + const name = seat.local && onOpenBot + ? h('button', { key: seat.key, type: 'button', className: 'bt-face-bot', title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) @@ -347,7 +390,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + }) + return h('div', { className, role: 'listitem', 'data-flybot': 'face-person', 'data-mine': person.mine || undefined }, + h('span', { className: 'bt-gs-who bt-face-person' }, -+ h(PersonMark, { size, name: person.name }), ++ h('span', { className: 'bt-face-marks', 'data-flybot': 'face-person-avatars' }, faceMarks(person, roster, size)), + h('span', { className: 'bt-face-person-text' }, + h('span', { className: 'bt-row-title' }, facePersonLabel(person)), + h('span', { className: 'bt-face-bots' }, bots)))) @@ -439,7 +482,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), - h(Card, { title: t('Members · {count}', { count: members.length }) }, + face ? h(Card, { title: t('Members · {count}', { count: people.length }) }, -+ people.map(person => h(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null, ++ people.map(person => h(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room.face?.lead?.handle }))) : null, + face ? null : h(Card, { title: t('Members · {count}', { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => h('div', { key: person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, + h('span', { className: 'bt-gs-who' }, @@ -524,10 +567,12 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js if (done && room) onCreated(room) else setBusy(false) } -@@ -157,6 +157,13 @@ export const GROUP_SETTINGS_CSS = ` +@@ -157,6 +157,15 @@ export const GROUP_SETTINGS_CSS = ` .bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px} .bt-gs-add{gap:8px;color:var(--bt-ink-2)} ++.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px} ++.bt-face-marks>.bt-mark{flex:none} +.bt-face-person{cursor:default} +.bt-face-person:hover .bt-row-title{text-decoration:none} +.bt-face-person-text{display:flex;flex-direction:column;min-width:0} diff --git a/patches/ds-bot/files/test/face-member-avatar.test.js b/patches/ds-bot/files/test/face-member-avatar.test.js new file mode 100644 index 00000000..67e6a7c2 --- /dev/null +++ b/patches/ds-bot/files/test/face-member-avatar.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #1104: room details drew one pink S for SelfA1025 and SelfB1025. The chat already +// tells those Chiefs apart (local whale, the friend's same whale stepped aside). + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const settings = await load('group-settings.js') +const { FacePersonRow } = settings +const { facePeople } = await load('face-seats.js') +const { personInitial } = await load('mark.js') + +const HANDLE = '@chief.selfa1025.home' +const chiefB = { id: 'b1', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } +const chiefA = { + id: 'remote-a', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, + remote: { handle: HANDLE, homeName: 'SelfA1025', owner: 'SelfA1025' }, +} +const seatB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' } +const seatA = { + kind: 'bot', remote: true, id: HANDLE, handle: HANDLE, displayName: 'Chief', ownerName: 'SelfA1025', + color: '#4d6bfe', avatar: { shape: 'whale' }, +} + +function roster(participants = [seatB, seatA], bots = [chiefB, chiefA]) { + return { + self: { displayName: 'SelfB1025' }, + bots, + byId: { b1: chiefB }, + roomsById: { r: { id: 'r', origin: 'face-to-face', name: '739184', members: ['b1'], participants } }, + } +} + +function rowsOf(view) { + settings.setLocalBots?.(view.bots) + const people = facePeople(view.roomsById.r, view) + return people.map(person => expand(FacePersonRow({ person, roster: view }), ['BotMark', 'PersonMark'])) +} +const shapesOf = row => find(row, n => typeof n.props?.['data-shape'] === 'string').map(n => n.props['data-shape']) + +test('#1104: SelfA1025 and SelfB1025 share an initial, so a letter mark cannot tell the homes apart', () => { + const view = roster() + const people = facePeople(view.roomsById.r, view) + // Copy out of the bundled realm; a vm array is not reference-equal to a host array. + assert.deepEqual(JSON.parse(JSON.stringify(people.map(person => person.name))), ['SelfB1025', 'SelfA1025']) + assert.equal(personInitial(people[0].name), 'S') + assert.equal(personInitial(people[1].name), 'S') +}) + +test('#1104: member rows draw each home\'s chat look, not the same initial', () => { + const drawn = rowsOf(roster()) + const shapes = drawn.map(shapesOf) + assert.deepEqual(shapes[0], ['whale'], 'my Chief stays the whale the sidebar and the transcript draw') + assert.equal(shapes[1].length, 1) + assert.notEqual(shapes[1][0], 'whale', 'the friend\'s same whale is stepped, as the chat mark is') + assert.equal(find(drawn, n => n.props?.['data-initial'] === 'S').length, 0) + for (const row of drawn) { + const mark = find(row, n => typeof n.props?.['data-shape'] === 'string')[0] + assert.ok(mark) + assert.equal(find(mark, n => n.type === 'svg').length, 1, 'a character, not a blank disc') + } + if (typeof settings.faceMemberLook === 'function' && typeof settings.faceSeatBot === 'function' && typeof settings.lookKey === 'function') { + const view = roster() + settings.setLocalBots(view.bots) + const people = facePeople(view.roomsById.r, view) + const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)))) + assert.match(keys[0], /^shape:whale:/) + assert.notEqual(keys[0], keys[1]) + assert.deepEqual(shapes, people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape])) + } +}) + +test('#1104: a seat with no stored look still draws a seeded character (#1089)', () => { + const bareA = { kind: 'bot', remote: true, id: '@chief.selfa1025.home', handle: '@chief.selfa1025.home', displayName: 'Chief', ownerName: 'SelfA1025' } + const bareB = { kind: 'bot', id: 'b1', handle: '@chief.selfb1025.home', displayName: 'Chief', ownerName: 'SelfB1025' } + const plain = { id: 'b1', name: 'Chief' } + const drawn = rowsOf({ + self: { displayName: 'SelfB1025' }, + bots: [plain], + byId: { b1: plain }, + roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } }, + }) + const shapes = drawn.map(shapesOf) + assert.equal(shapes.length, 2) + for (const shape of shapes) { + assert.equal(shape.length, 1) + assert.match(shape[0], /^[a-z]/) + } + assert.equal(find(drawn, n => n.props?.['data-initial']).length, 0) + const view = { + self: { displayName: 'SelfB1025' }, + bots: [plain], + byId: { b1: plain }, + roomsById: { r: { id: 'r', origin: 'face-to-face', members: ['b1'], participants: [bareB, bareA] } }, + } + settings.setLocalBots(view.bots) + const people = facePeople(view.roomsById.r, view) + const keys = people.map(person => settings.lookKey(settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)))) + assert.equal(keys.length, 2) + assert.notEqual(keys[0], keys[1], 'a seeded shape or color still tells the homes apart') + const looked = people.map(person => [settings.faceMemberLook(settings.faceSeatBot(person.seats[0], view)).shape]) + assert.equal(JSON.stringify(shapes), JSON.stringify(looked)) +}) diff --git a/patches/ds-bot/files/test/face-person-first.test.js b/patches/ds-bot/files/test/face-person-first.test.js index d5b90c61..992b3fb3 100644 --- a/patches/ds-bot/files/test/face-person-first.test.js +++ b/patches/ds-bot/files/test/face-person-first.test.js @@ -73,13 +73,14 @@ test('one entry per person, mine first, "(you)" only on the person, count = peop assert.equal(roomSubtitle(roster.roomsById.r, roster), '2 people · 3 Bots') const opened = [] - const row = expand(FacePersonRow({ person: people[1], onOpenBot: id => opened.push(id) }), ['PersonMark']) + const row = expand(FacePersonRow({ person: people[1], roster, onOpenBot: id => opened.push(id) }), ['BotMark']) assert.equal(row.props['data-flybot'], 'face-person') - assert.equal(find(row, n => String(n.props?.className ?? '').split(' ').includes('bt-person-mark')).length, 1, 'a person mark, not a Bot avatar') + assert.equal(find(row, n => typeof n.props?.['data-shape'] === 'string').length, 2, 'each Bot the person brought, not one shared initial') + assert.equal(find(row, n => n.props?.['data-initial']).length, 0) const words = node => text(find(node, n => n.props?.className === 'bt-face-person-text')[0]) assert.match(words(row), /^SelfA0056Chief, Researcher$/) assert.equal(find(row, n => n.type === 'button').length, 0, "another person's Bots are not my chats") - const mine = expand(FacePersonRow({ person: people[0], onOpenBot: id => opened.push(id) }), ['PersonMark']) + const mine = expand(FacePersonRow({ person: people[0], roster, onOpenBot: id => opened.push(id) }), ['BotMark']) assert.equal(mine.props['data-mine'], true) assert.match(words(mine), /^SelfB0056 \(you\)Chief$/) find(mine, n => n.type === 'button')[0].props.onClick()