From ff72578c1f647aedc180cae83ad529f40818c956 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:56:15 +0800 Subject: [PATCH] fix(palette): read a search row through the same line reader as a preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search row and a sidebar preview now read a stored line with one function, `readableLine`, so a fence, a header and a flattened delegation trace cannot be read two ways (#1347). Its `names` are the conversation's real Bot names: only a prefix whose every hop is one of them is a trace, whole names longest first, so `Settings → Models 页面` keeps its words while `Chief → 助手 Hi there` loses its routing (#1408). An empty turn is no line at all: the walk keeps going to older turns, returning nothing only when none is left — the back-fill #1408 item 3 found missing. Refs #1394 Docs-not-needed: no public API change; this only changes how the client reads a stored line for a palette row. --- core/client.js | 37 +- core/src/client/inbox.js | 8 +- core/src/client/palette.js | 9 +- core/src/client/sidebar.js | 6 +- core/src/client/text.js | 49 +- core/test/inbound-ui.test.js | 7 +- core/test/palette-preview.test.js | 165 ++ patches/ds-bot/0001-core-fixes.patch | 55 +- patches/ds-bot/0003-flybot-api-client.patch | 39 +- patches/ds-bot/0008-client-bundle.patch | 1655 +++++++++-------- patches/ds-bot/files/src/client/inbox.js | 8 +- patches/ds-bot/files/test/inbound-ui.test.js | 7 +- .../ds-bot/files/test/palette-preview.test.js | 165 ++ 13 files changed, 1356 insertions(+), 854 deletions(-) create mode 100644 core/test/palette-preview.test.js create mode 100644 patches/ds-bot/files/test/palette-preview.test.js diff --git a/core/client.js b/core/client.js index 1482d2479..6c8d57a45 100644 --- a/core/client.js +++ b/core/client.js @@ -2501,17 +2501,36 @@ function heldReplyOf(bot, sessionId, turn) { return found; } var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); -function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { +function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; for (let index = outline.length - 1; index >= 0; index -= 1) { const entry = outline[index]; if (hiddenTurns.includes(entry.turn)) continue; const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; - if (text !== "") return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, "")); + if (text === "") continue; + return readableLine(text, names); } return ""; } +function readableLine(text, names = []) { + return dropKnownTrace(collapsedLine(text), names); +} +function collapsedLine(text) { + return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, " ").trim()); +} +function dropKnownTrace(line2, names) { + const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length); + if (known.length === 0 || line2 === "") return line2; + const hop = known.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)`; + const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`); + const match = trace.exec(line2); + if (!match) return line2; + const rest = line2.slice(match[0].length); + return /^[\s,,.。::]*$/.test(rest) ? "" : rest; +} +var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; @@ -7646,7 +7665,7 @@ function latestActivity(card, { roster, work, sessions }) { const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; if (entry) options.push(`${workPhrase(entry.tool)}…`); } - if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))); + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; } function toolCalls(card, { roster, work }) { @@ -7758,7 +7777,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() const bot = ownBot(roster, entry.botId); if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; const chat = entry.roomId ?? bot.id; - const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ""; + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : ""; needs.push({ id: `turn:${entry.id}`, kind: "turn", @@ -7801,7 +7820,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() const part = currentPart(bot); const at = sessions.byId[part]?.updatedAt; if (!fresh(at)) continue; - const text = previewOf(sessions, part); + const text = previewOf(sessions, part, [], [], botNames(roster)); if (text === "") continue; done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); } @@ -8853,7 +8872,7 @@ var workLine = (lead, tool, more) => (0, import_react16.createElement)(Shimmer, function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { const id = bot.id; const part = currentPart(bot); - const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster)); + const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); const running = useSessionStatus((map) => map.get(part)?.running === true); const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; @@ -8988,7 +9007,7 @@ function FaceMark() { } function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room2.id; - const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster)); + const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); const preview = nameMentions(visibleText(room2.preview), roster) || fallback; const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; @@ -13305,6 +13324,7 @@ button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-unde // src/client/palette.js var import_react29 = require("react"); var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); +var paletteText = (raw, names) => readableLine(String(raw ?? ""), names); var PALETTE_EXIT_MS = 160; function CommandPalette({ start, token, roster, actions, useSessions }) { const [query, setQuery] = (0, import_react29.useState)(start ?? ""); @@ -13318,6 +13338,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { }; useEscape(close); const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; + const names = botNames(roster); const found = useSessions((list) => { if (wanted.length < 2) return "[]"; const hits = []; @@ -13329,7 +13350,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { const entry = outline[index2]; if (hidden.includes(entry.turn)) continue; for (const raw of [entry.response, entry.prompt]) { - const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); + const text = paletteText(raw, names); const at = text.toLowerCase().indexOf(wanted); if (at < 0) continue; const from = Math.max(0, at - 24); diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index 0febe9a34..beafd6545 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -14,7 +14,7 @@ import { workPhrase } from './cells.js' import { elapsedLabel } from './activity-row.js' import { useEscape } from './overlay-hooks.js' import { alwaysChoices } from './member-status.js' -import { currentPart, previewOf, relativeShort } from './text.js' +import { botNames, currentPart, previewOf, relativeShort } from './text.js' import { hostText, t } from './i18n.js' import { failureSentence } from '../shared/failure-copy.js' @@ -129,7 +129,7 @@ function latestActivity(card, { roster, work, sessions }) { const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined if (entry) options.push(`${workPhrase(entry.tool)}…`) } - if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))) + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))) return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' } @@ -230,7 +230,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date const bot = ownBot(roster, entry.botId) if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue const chat = entry.roomId ?? bot.id - const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : '' + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : '' needs.push({ id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at, title: asked || t('The last turn did not finish'), reason: entry.failed, @@ -261,7 +261,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date const part = currentPart(bot) const at = sessions.byId[part]?.updatedAt if (!fresh(at)) continue - const text = previewOf(sessions, part) + const text = previewOf(sessions, part, [], [], botNames(roster)) if (text === '') continue done.push({ id: `chat:${bot.id}`, kind: 'chat', status: 'done', bot, at, title: text, place: '', chat: bot.id, ...people(roster, bot.id) }) } diff --git a/core/src/client/palette.js b/core/src/client/palette.js index c8c5f6b95..a8d042a57 100644 --- a/core/src/client/palette.js +++ b/core/src/client/palette.js @@ -1,7 +1,7 @@ import { createElement as h, Fragment, useEffect, useState } from 'react' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import { isMainOf } from './sources.js' -import { currentPart, stripHeader } from './text.js' +import { botNames, currentPart, readableLine } from './text.js' import { PlusIcon, SearchIcon, PeopleIcon, CloseIcon, GearIcon } from './icons.js' import { BotAvatar, RoomAvatar } from './mark.js' import { useEscape } from './overlay-hooks.js' @@ -12,7 +12,9 @@ import { seatedCount } from './group-settings.js' // Search is a command palette: a 560px dialog, a search header, and 49px // rows (24px avatar, title, subtitle). Bots and groups match on name and role; past -// messages match on their text. +// messages match on their text, read by `readableLine`, the same reader `previewOf` uses — a header, +// a delegation trace and a fence are routing or data, never the row (#1347). +const paletteText = (raw, names) => readableLine(String(raw ?? ''), names) const PALETTE_EXIT_MS = 160 export function CommandPalette({ start, token, roster, actions, useSessions }) { const [query, setQuery] = useState(start ?? '') @@ -26,6 +28,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { } useEscape(close) const ids = [...roster.bots.map(currentPart), ...roster.rooms.map(item => item.id)] + const names = botNames(roster) const found = useSessions((list) => { if (wanted.length < 2) return '[]' const hits = [] @@ -37,7 +40,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { const entry = outline[index] if (hidden.includes(entry.turn)) continue for (const raw of [entry.response, entry.prompt]) { - const text = stripHeader(raw ?? '').replace(/\s+/g, ' ').trim() + const text = paletteText(raw, names) const at = text.toLowerCase().indexOf(wanted) if (at < 0) continue const from = Math.max(0, at - 24) diff --git a/core/src/client/sidebar.js b/core/src/client/sidebar.js index 6f7446567..bb94a1ffe 100644 --- a/core/src/client/sidebar.js +++ b/core/src/client/sidebar.js @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us import { createPortal } from 'react-dom' import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' import { isMainOf } from './sources.js' -import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { awayGroups, botNames, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -45,7 +45,7 @@ const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { const id = bot.id const part = currentPart(bot) - const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster)) + const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)) const running = useSessionStatus(map => map.get(part)?.running === true) const active = useSessionRetainInfo(part, info => (info?.retainedBy?.mainView ?? 0) > 0) const unread = useSessionStatus(map => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== undefined) || (marked && !active) @@ -149,7 +149,7 @@ function FaceMark() { function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { const id = room.id - const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) + const fallback = useSessions(list => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)) // The host's line is the same kind of stored text as a Bot chat's, so the row reads it the same way: // a fence or a marker fragment must never reach the row (#1347). const preview = nameMentions(visibleText(room.preview), roster) || fallback diff --git a/core/src/client/text.js b/core/src/client/text.js index 5afbb32aa..f65d91fc0 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -276,21 +276,62 @@ export function heldReplyOf(bot, sessionId, turn) { export const unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []) .filter(reply => reply.sessionId === sessionId && reply.status !== 'approved' && reply.status !== 'auto').map(reply => reply.turn) +// `previewOf(list, id, hiddenTurns, unsentTurns, names)`: the newest visible line of that Session. An +// empty turn is no line at all, so the walk keeps going to older turns; only a walk that ends still +// empty reads as nothing. The line it settles on is read by `readableLine`, the one reader a search row +// uses too, with the same `names`. // `unsentTurns`: turns whose reply is held or was not sent; the preview shows their prompt instead. -export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { +// `names`: the conversation's real Bot names; a delegation trace is only a trace when every hop is one +// of them, so `Settings → Models 页面` stays a line while `Chief → 助手 Hi there` loses its routing. +export function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline if (!Array.isArray(outline)) return '' for (let index = outline.length - 1; index >= 0; index -= 1) { const entry = outline[index] if (hiddenTurns.includes(entry.turn)) continue const text = (unsentTurns.includes(entry.turn) ? '' : entry.response) || entry.prompt || '' - // The outline flattens a reply to one line, so its trace ends at a space there. A stored line can - // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1347). - if (text !== '') return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '')) + if (text === '') continue + return readableLine(text, names) } return '' } +// `readableLine(text, names)`: one stored line a caller already holds — the line `previewOf` settled on, +// or a line the palette renders as a search row — read the one way a person reads it, so a row cannot +// have a second reading of what a preview shows (#1347). `names` is the conversation's real Bot names, +// this one included; with no names nothing that merely looks like a trace is removed (#1408). +export function readableLine(text, names = []) { + return dropKnownTrace(collapsedLine(text), names) +} + +// The one sanitized reading of a stored line: no header, no fence, no wrap, one line of words. +function collapsedLine(text) { + return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, ' ').trim()) +} + +// The outline flattens a reply to one line, so a delegation trace arrives as a prefix: `Chief → 助手 Hi` +// is routing plus words, while `Settings → Models 页面` is a line someone wrote. Only a prefix whose +// every hop is a known name — longest name first — is removed, and only when words follow it; a hop may +// carry punctuation and a clock (`Chief → Product,03:16Z 审计。`, #1408). +function dropKnownTrace(line, names) { + const known = [...new Set((Array.isArray(names) ? names : []).map(name => String(name ?? '').trim()).filter(name => name !== ''))].sort((a, b) => b.length - a.length) + if (known.length === 0 || line === '') return line + const hop = known.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + // A hop is a whole name: `Chief → 助手们 Hi` is not `Chief → 助手` plus words. + const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)` + const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`) + const match = trace.exec(line) + if (!match) return line + // A trace with nothing after it, or only its own punctuation, is no row at all. + const rest = line.slice(match[0].length) + return /^[\s,,.。::]*$/.test(rest) ? '' : rest +} + +// The names a stored delegation trace can be built from: the roster's Bots, remote Bots and agents. +// A chat whose roster is not loaded still reads its text, just never as a trace. +export const botNames = roster => [...(roster?.bots ?? []), ...(roster?.remoteBots ?? []), ...(roster?.agents ?? [])] + .map(entry => entry?.name).filter(name => typeof name === 'string' && name !== '') + const formatTokens = count => (count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count)) // Same buckets as the shell's usage pill: every prompt-side billing bucket plus output. diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index 03e1f356c..71e50f932 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -42,12 +42,13 @@ test('ordinary text that merely mentions an arrow is left alone', () => { }) test('the sidebar preview drops the trace too', () => { + const names = ['Chief', '助手'] const list = { byId: { s: { projectionValues: { turnOutline: [{ turn: 2, prompt: '', response: 'Chief → 助手\n\nHi Chief!' }] } } } } - assert.equal(previewOf(list, 's'), 'Hi Chief!') + assert.equal(previewOf(list, 's', [], [], names), 'Hi Chief!') const flat = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Chief → 助手 Hi! Message received.' }] } } } } - assert.equal(previewOf(flat, 's'), 'Hi! Message received.') + assert.equal(previewOf(flat, 's', [], [], names), 'Hi! Message received.') const plain = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Use Settings → Models first' }] } } } } - assert.equal(previewOf(plain, 's'), 'Use Settings → Models first') + assert.equal(previewOf(plain, 's', [], [], names), 'Use Settings → Models first') }) test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), else a stable look from its handle", () => { diff --git a/core/test/palette-preview.test.js b/core/test/palette-preview.test.js new file mode 100644 index 000000000..75c1c3d80 --- /dev/null +++ b/core/test/palette-preview.test.js @@ -0,0 +1,165 @@ +// The command palette's message rows are read by a person, like every other preview row (#1347): +// the header and a delegation trace are routing, and an untrusted fence is data. A search matches +// the words of the line, never the marker's attributes. +// +// The invariant each case pins is not a hand-written string but the live `previewOf` read of the same +// stored shape: a row is what a preview shows, or the palette has a second reading of stored text. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { botNames, previewOf, readableLine } from '../src/client/text.js' + +const { outputFiles } = await build({ + stdin: { contents: "export { CommandPalette } from './src/client/palette.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)) }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat() } }) +const react = { + createElement: h, Fragment: Symbol('F'), + useEffect() {}, useLayoutEffect() {}, + useState: (value) => [typeof value === 'function' ? value() : value, () => {}], + useRef: (current) => ({ current }), +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false }, window: {}, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { FishLogo: () => null, MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { CommandPalette } = module.exports + +// The conversation's real Bot names: only these can make a prefix a delegation trace (#1408). +const BOTS = [ + { id: 's1', sessionId: 's1', name: 'Chief' }, + { id: 's2', sessionId: 's2', name: '助手' }, + { id: 's3', sessionId: 's3', name: 'Dev Lead' }, + { id: 's4', sessionId: 's4', name: 'Dev-1' }, + { id: 's5', sessionId: 's5', name: 'Product' }, +] +const ROSTER = { bots: BOTS, byId: Object.fromEntries(BOTS.map(bot => [bot.id, bot])), rooms: [], roomsById: {}, agents: [], exchangeTurns: {} } +const NAMES = botNames(ROSTER) + +// The same stored line as the Session outline holds it, so `previewOf` is asked the question a +// sidebar row asks. +const outlineOf = (raw) => ({ projectionsBySession: { s1: { values: { turnOutline: [{ turn: 1, prompt: raw }] } } }, byId: {} }) + +// What the palette's message section found for `query` over that session's outline: [[session, turn, row]]. +function hits(query, raw) { + let found + const useSessions = (selector) => { found = selector(outlineOf(raw)); return found } + CommandPalette({ start: query, token: 't', roster: ROSTER, actions: { closePalette() {}, openSession() {} }, useSessions }) + return JSON.parse(found) +} + +// One stored shape, one row: the rendered row text must be byte-for-byte what `previewOf` reads of the +// same line, and `readableLine(text, names)` must be the same read as `previewOf(list, id, …, names)`. +function rowIsPreview(query, raw, message) { + const expected = previewOf(outlineOf(raw), 's1', [], [], NAMES) + const found = hits(query, raw) + assert.equal(found.length, 1, `the words of "${message}" are found`) + assert.equal(found[0][2], expected, `the row is what previewOf reads for "${message}"`) + assert.equal(expected, readableLine(raw, NAMES), `readableLine(text, names) is previewOf(list, id) for "${message}"`) + return found[0][2] +} + +// The outline flattens a reply to one line, so a flattened trace can carry its own spacing (#1347). +test('#1347: a trace indented with spaces is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief → 助手 Hi there', 'a space-indented trace') +}) + +test('#1347: a trace split by tabs is read as previewOf reads it', () => { + rowIsPreview('hi', 'Chief\t→\t助手 Hi there', 'a tab-separated trace') +}) + +test('#1347: a trace with doubled spaces is read as previewOf reads it', () => { + rowIsPreview('hi', 'Chief → 助手 Hi there', 'a double-spaced trace') +}) + +test('#1347: a trace with mixed indentation is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief \t → \t 助手 Hi there', 'a mixed-whitespace trace') +}) + +test('#1347: a trace after a header is read as previewOf reads it', () => { + rowIsPreview('hi', '[Reply from 助手]\n Chief → 助手 Hi there', 'a header then an indented trace') +}) + +test('#1347: a wrapped multi-line line is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief → 助手 Hi, there\nsecond line', 'a wrapped line with a flattened trace') +}) + +test('#1347: a fence fragment after a trace is read as previewOf reads it', () => { + const row = rowIsPreview('hi', 'Chief\t→\t助手 Hi ', 'a trace then a fence fragment') + assert.doesNotMatch(row, /untrusted-message|from="/) + + // A complete wrap reads the same way: only the words, and the marker's own words find nothing. + const wrapped = '[Message from User (user@friend)]\n\nB踢后测4\n' + assert.equal(previewOf(outlineOf(wrapped), 's1', [], [], NAMES), readableLine(wrapped, NAMES)) + assert.equal(hits('b踢', wrapped)[0][2], readableLine(wrapped, NAMES)) + for (const attribute of ['from', 'user', 'untrusted']) assert.deepEqual(hits(attribute, wrapped), [], `"${attribute}" belongs to the marker, not the line`) +}) + +// #1408 shape 1: the fence is taken before the trace, so a trace the fence wrapped is still routing. +test('#1408: a trace inside a fence reads as its words', () => { + const raw = '\nChief → 助手\n\nHi there\n' + assert.equal(readableLine(raw, NAMES), 'Hi there') + assert.equal(previewOf(outlineOf(raw), 's1', [], [], NAMES), 'Hi there') +}) + +// #1408 shape 2: a name is what makes the prefix a trace — an ordinary arrow line keeps every word, +// and stays findable by any of them. +test('#1408: an ordinary arrow line is not a trace', () => { + const raw = '[Message from Chief]\nSettings → Models 页面' + const expected = readableLine(raw, NAMES) + assert.equal(expected, 'Settings → Models 页面') + for (const query of ['Settings', 'Models', '页面']) { + const found = hits(query, raw) + assert.equal(found.length, 1, `"${query}" is found`) + assert.equal(found[0][2], expected, `the row keeps its words for "${query}"`) + } + assert.equal(readableLine('a → b c', NAMES), 'a → b c') +}) + +// #1408 shape 2, the other edge: a real trace loses its routing, whole names only, and a clock on its +// tail is routing too — never content, and never a row of its own. +test('#1408: a known-name trace loses its routing, whole names only', () => { + const cases = [ + ['Chief → Product,03:16Z 审计。**defect 无**', '审计。**defect 无**'], + ['Chief → Product 03:16Z 审计', '审计'], + ['Chief → Dev Lead → Dev-1 点头', '点头'], + ['Chief → Dev Lead 点头', '点头'], + ['Dev Lead → Chief. Base note is up', 'Base note is up'], + ['Chief → 助手 Hi there', 'Hi there'], + ['Chief → Product,03:16Z', ''], + ['Dev → Lead 点头', 'Dev → Lead 点头'], + ['Chief → 助手们 Hi there', 'Chief → 助手们 Hi there'], + ] + for (const [raw, expected] of cases) assert.equal(readableLine(raw, NAMES), expected) +}) + +// Without names nothing that only looks like a trace is removed: a caller that has no roster reads +// the line as before (#1408). +test('#1408: no names, no trace removal', () => { + assert.equal(readableLine('Chief → 助手 Hi there'), 'Chief → 助手 Hi there') + assert.equal(readableLine('Settings → Models 页面'), 'Settings → Models 页面') +}) + +// An empty turn is no line at all: the walk keeps going to an older turn, as a sidebar row does. +// #1408 shape 3: the back-fill `previewOf` did before `break` made it unreachable. +test('previewOf falls back to an older turn when the newest turn is empty', () => { + const outline = (turns) => ({ projectionsBySession: { s1: { values: { turnOutline: turns } } } }) + const empty = outline([{ turn: 1, response: 'older reply', prompt: 'q1' }, { turn: 2, response: '', prompt: '' }]) + assert.equal(previewOf(empty, 's1'), 'older reply') + assert.equal(previewOf(empty, 's1', [], [], NAMES), 'older reply') + + // An unsent turn still shows its prompt; the fallback must not swallow it. + const unsent = outline([{ turn: 1, response: 'older reply', prompt: 'q1' }, { turn: 2, response: 'held reply', prompt: 'What is 2+2?' }]) + assert.equal(previewOf(unsent, 's1', [], [2]), 'What is 2+2?') +}) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 73efcedb1..421665e0f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2378,7 +2378,7 @@ diff --git a/src/client/text.js b/src/client/text.js // parts) are not events in the chat, nor is a request handed back after its line was // already shown: deferred, resumed after a restart, or retried after a tool call // written as text. -@@ -200,14 +200,27 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -200,18 +200,72 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { } // Latest visible line for a sidebar row; Bot-to-Bot exchange turns stay out of it. @@ -2393,8 +2393,14 @@ diff --git a/src/client/text.js b/src/client/text.js +export const unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []) + .filter(reply => reply.sessionId === sessionId && reply.status !== 'approved' && reply.status !== 'auto').map(reply => reply.turn) + ++// `previewOf(list, id, hiddenTurns, unsentTurns, names)`: the newest visible line of that Session. An ++// empty turn is no line at all, so the walk keeps going to older turns; only a walk that ends still ++// empty reads as nothing. The line it settles on is read by `readableLine`, the one reader a search row ++// uses too, with the same `names`. +// `unsentTurns`: turns whose reply is held or was not sent; the preview shows their prompt instead. -+export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { ++// `names`: the conversation's real Bot names; a delegation trace is only a trace when every hop is one ++// of them, so `Settings → Models 页面` stays a line while `Chief → 助手 Hi there` loses its routing. ++export function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline if (!Array.isArray(outline)) return '' for (let index = outline.length - 1; index >= 0; index -= 1) { @@ -2403,12 +2409,51 @@ diff --git a/src/client/text.js b/src/client/text.js - const text = entry.response || entry.prompt || '' - if (text !== '') return stripHeader(text).replace(/\s+/g, ' ').trim() + const text = (unsentTurns.includes(entry.turn) ? '' : entry.response) || entry.prompt || '' -+ // The outline flattens a reply to one line, so its trace ends at a space there. A stored line can -+ // still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1347). -+ if (text !== '') return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, '')) ++ if (text === '') continue ++ return readableLine(text, names) } return '' } + ++// `readableLine(text, names)`: one stored line a caller already holds — the line `previewOf` settled on, ++// or a line the palette renders as a search row — read the one way a person reads it, so a row cannot ++// have a second reading of what a preview shows (#1347). `names` is the conversation's real Bot names, ++// this one included; with no names nothing that merely looks like a trace is removed (#1408). ++export function readableLine(text, names = []) { ++ return dropKnownTrace(collapsedLine(text), names) ++} ++ ++// The one sanitized reading of a stored line: no header, no fence, no wrap, one line of words. ++function collapsedLine(text) { ++ return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, ' ').trim()) ++} ++ ++// The outline flattens a reply to one line, so a delegation trace arrives as a prefix: `Chief → 助手 Hi` ++// is routing plus words, while `Settings → Models 页面` is a line someone wrote. Only a prefix whose ++// every hop is a known name — longest name first — is removed, and only when words follow it; a hop may ++// carry punctuation and a clock (`Chief → Product,03:16Z 审计。`, #1408). ++function dropKnownTrace(line, names) { ++ const known = [...new Set((Array.isArray(names) ? names : []).map(name => String(name ?? '').trim()).filter(name => name !== ''))].sort((a, b) => b.length - a.length) ++ if (known.length === 0 || line === '') return line ++ const hop = known.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') ++ // A hop is a whole name: `Chief → 助手们 Hi` is not `Chief → 助手` plus words. ++ const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)` ++ const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`) ++ const match = trace.exec(line) ++ if (!match) return line ++ // A trace with nothing after it, or only its own punctuation, is no row at all. ++ const rest = line.slice(match[0].length) ++ return /^[\s,,.。::]*$/.test(rest) ? '' : rest ++} ++ ++// The names a stored delegation trace can be built from: the roster's Bots, remote Bots and agents. ++// A chat whose roster is not loaded still reads its text, just never as a trace. ++export const botNames = roster => [...(roster?.bots ?? []), ...(roster?.remoteBots ?? []), ...(roster?.agents ?? [])] ++ .map(entry => entry?.name).filter(name => typeof name === 'string' && name !== '') ++ + const formatTokens = count => (count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count)) + + // Same buckets as the shell's usage pill: every prompt-side billing bucket plus output. @@ -230,3 +230,35 @@ export function usageOf(list, ids) { const hit = input > 0 ? Math.round((cacheRead / input) * 100) : 0 return t('{tokens} tok · Cache hit {hit}%', { tokens: formatTokens(input + output), hit }) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e11c3b0fa..f5f3cfb55 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2016,7 +2016,14 @@ diff --git a/src/client/overlays.js b/src/client/overlays.js diff --git a/src/client/palette.js b/src/client/palette.js --- a/src/client/palette.js +++ b/src/client/palette.js -@@ -7,6 +7,8 @@ import { BotAvatar, RoomAvatar } from './mark.js' +@@ -1,16 +1,20 @@ + import { createElement as h, Fragment, useEffect, useState } from 'react' + import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' + import { isMainOf } from './sources.js' +-import { currentPart, stripHeader } from './text.js' ++import { botNames, currentPart, readableLine } from './text.js' + import { PlusIcon, SearchIcon, PeopleIcon, CloseIcon, GearIcon } from './icons.js' + import { BotAvatar, RoomAvatar } from './mark.js' import { useEscape } from './overlay-hooks.js' import { QUICK_EDGES, useFlip, useGlide } from './motion.js' import { t } from './i18n.js' @@ -2025,6 +2032,30 @@ diff --git a/src/client/palette.js b/src/client/palette.js // Search is a command palette: a 560px dialog, a search header, and 49px // rows (24px avatar, title, subtitle). Bots and groups match on name and role; past +-// messages match on their text. ++// messages match on their text, read by `readableLine`, the same reader `previewOf` uses — a header, ++// a delegation trace and a fence are routing or data, never the row (#1347). ++const paletteText = (raw, names) => readableLine(String(raw ?? ''), names) + const PALETTE_EXIT_MS = 160 + export function CommandPalette({ start, token, roster, actions, useSessions }) { + const [query, setQuery] = useState(start ?? '') +@@ -24,6 +24,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { + } + useEscape(close) + const ids = [...roster.bots.map(currentPart), ...roster.rooms.map(item => item.id)] ++ const names = botNames(roster) + const found = useSessions((list) => { + if (wanted.length < 2) return '[]' + const hits = [] +@@ -35,7 +35,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { + const entry = outline[index] + if (hidden.includes(entry.turn)) continue + for (const raw of [entry.response, entry.prompt]) { +- const text = stripHeader(raw ?? '').replace(/\s+/g, ' ').trim() ++ const text = paletteText(raw, names) + const at = text.toLowerCase().indexOf(wanted) + if (at < 0) continue + const from = Math.max(0, at - 24) @@ -49,13 +49,13 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) { const matchesName = (...fields) => wanted === '' || fields.some(field => (field ?? '').toLowerCase().includes(wanted)) const sections = [] @@ -2063,7 +2094,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js import { isMainOf } from './sources.js' -import { currentPart, previewOf, relativeShort } from './text.js' -import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon } from './icons.js' -+import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' ++import { awayGroups, botNames, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js' +import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js' import { MAIN_LOOK } from './characters.js' import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' @@ -2088,7 +2119,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js const id = bot.id const part = currentPart(bot) - const preview = useSessions(list => previewOf(list, part, roster.exchangeTurns[part])) -+ const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster)) ++ const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)) const running = useSessionStatus(map => map.get(part)?.running === true) const active = useSessionRetainInfo(part, info => (info?.retainedBy?.mainView ?? 0) > 0) const unread = useSessionStatus(map => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== undefined) || (marked && !active) @@ -2205,7 +2236,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js const id = room.id - const fallback = useSessions(list => previewOf(list, id)) - const preview = room.preview?.replace(/\s+/g, ' ').trim() || fallback -+ const fallback = useSessions(list => nameMentions(previewOf(list, id), roster)) ++ const fallback = useSessions(list => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)) + // The host's line is the same kind of stored text as a Bot chat's, so the row reads it the same way: + // a fence or a marker fragment must never reach the row (#1347). + const preview = nameMentions(visibleText(room.preview), roster) || fallback diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 8b969be5c..714c3475e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1742,7 +1742,7 @@ diff --git a/client.js b/client.js var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; function toolArgs(root) { if (typeof root.args?.text === "function") { -@@ -1597,14 +1597,21 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -1597,17 +1597,43 @@ function commRunOf(order, peers, turn, hiddenTurns = []) { } return { count, peerIds }; } @@ -1754,7 +1754,7 @@ diff --git a/client.js b/client.js + return found; +} +var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn); -+function previewOf(list, id, hiddenTurns = [], unsentTurns = []) { ++function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; for (let index = outline.length - 1; index >= 0; index -= 1) { @@ -1763,11 +1763,33 @@ diff --git a/client.js b/client.js - const text = entry.response || entry.prompt || ""; - if (text !== "") return stripHeader(text).replace(/\s+/g, " ").trim(); + const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || ""; -+ if (text !== "") return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, "")); ++ if (text === "") continue; ++ return readableLine(text, names); } return ""; } -@@ -1624,773 +1624,1849 @@ function usageOf(list, ids) { ++function readableLine(text, names = []) { ++ return dropKnownTrace(collapsedLine(text), names); ++} ++function collapsedLine(text) { ++ return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, " ").trim()); ++} ++function dropKnownTrace(line2, names) { ++ const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length); ++ if (known.length === 0 || line2 === "") return line2; ++ const hop = known.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); ++ const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)`; ++ const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`); ++ const match = trace.exec(line2); ++ if (!match) return line2; ++ const rest = line2.slice(match[0].length); ++ return /^[\s,,.。::]*$/.test(rest) ? "" : rest; ++} ++var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== ""); + var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); + function usageOf(list, ids) { + let input = 0; +@@ -1624,763 +1624,1839 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -1872,14 +1894,8 @@ diff --git a/client.js b/client.js + }; }, - stop: () => cancelAnimationFrame(frame) -+ set(next) { -+ const resolved = typeof next === "function" ? next(value) : next; -+ if (resolved === value) return; -+ value = resolved; -+ for (const listener of [...listeners2]) listener(); -+ } - }; - } +- }; +-} -var CHOSEN = '[aria-pressed="true"],[aria-selected="true"],[aria-current="page"]'; -function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trail = SPRING_TRAIL, box: given } = {}) { - const ownBox = (0, import_react.useRef)(null); @@ -1907,7 +1923,12 @@ diff --git a/client.js b/client.js - if (!chosen) { - knob.dataset.hidden = ""; - return; -- } ++ set(next) { ++ const resolved = typeof next === "function" ? next(value) : next; ++ if (resolved === value) return; ++ value = resolved; ++ for (const listener of [...listeners2]) listener(); + } - const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; - const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); - const { springs } = own.current; @@ -1918,18 +1939,6 @@ diff --git a/client.js b/client.js - const forward = before === null || start >= before.start; - springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); - container.dataset.glide = ""; -+var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; -+var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); -+function indexRoster(value) { -+ setLocalBots(value.bots); -+ setPluginTools(value.pluginTools); -+ const byId = {}; -+ const claim = (bot, id) => { -+ if (id === void 0 || id === null || id === "") return; -+ const prev = byId[id]; -+ if (prev && prev !== bot && !prev.remote && bot?.remote) return; -+ if (prev && prev !== bot && prev.id === id) return; -+ byId[id] = bot; }; - (0, import_react.useLayoutEffect)(() => { - const state = own.current; @@ -1950,6 +1959,28 @@ diff --git a/client.js b/client.js - own.current.springs.stop(); - }, []); - return { box, thumb }; + } +-function Segmented({ label, value, className, children }) { +- const glide = useGlide(value); +- return (0, import_react.createElement)( +- "div", +- { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, +- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- children +- ); ++var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; ++var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); ++function indexRoster(value) { ++ setLocalBots(value.bots); ++ setPluginTools(value.pluginTools); ++ const byId = {}; ++ const claim = (bot, id) => { ++ if (id === void 0 || id === null || id === "") return; ++ const prev = byId[id]; ++ if (prev && prev !== bot && !prev.remote && bot?.remote) return; ++ if (prev && prev !== bot && prev.id === id) return; ++ byId[id] = bot; ++ }; + for (const bot of value.bots) { + for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); + } @@ -1963,21 +1994,6 @@ diff --git a/client.js b/client.js + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; } --function Segmented({ label, value, className, children }) { -- const glide = useGlide(value); -- return (0, import_react.createElement)( -- "div", -- { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, -- (0, import_react.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- children -- ); -+function rosterBot(roster, id) { -+ if (id === void 0 || id === null || id === "") return void 0; -+ const list = roster?.bots ?? []; -+ const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); -+ if (own) return own; -+ return roster?.byId?.[id]; - } -function useFlip(list, key, selector, id = (node) => node.dataset.flip) { - const tops = (0, import_react.useRef)(null); - (0, import_react.useLayoutEffect)(() => { @@ -1999,11 +2015,12 @@ diff --git a/client.js b/client.js - } - tops.current = next; - }, [key]); -+function botByName(bots, name) { -+ const wanted = String(name ?? "").toLowerCase(); -+ if (wanted === "") return void 0; -+ const list = Array.isArray(bots) ? bots : []; -+ return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); ++function rosterBot(roster, id) { ++ if (id === void 0 || id === null || id === "") return void 0; ++ const list = roster?.bots ?? []; ++ const own = list.find((bot) => bot && !bot.remote && (bot.id === id || bot.sessionId === id || (bot.parts ?? []).includes(id))); ++ if (own) return own; ++ return roster?.byId?.[id]; } -function useMorph(box, key) { - const last = (0, import_react.useRef)({ key, height: null }); @@ -2033,13 +2050,28 @@ diff --git a/client.js b/client.js - if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; - }; - }); ++function botByName(bots, name) { ++ const wanted = String(name ?? "").toLowerCase(); ++ if (wanted === "") return void 0; ++ const list = Array.isArray(bots) ? bots : []; ++ return list.find((bot) => bot && !bot.remote && String(bot.name ?? "").toLowerCase() === wanted) ?? list.find((bot) => bot && String(bot.name ?? "").toLowerCase() === wanted); + } +-function useLinger(value, ms) { +- const [kept, setKept] = (0, import_react.useState)(value); +- (0, import_react.useEffect)(() => { +- if (value) { +- setKept(value); +- return void 0; +- } +- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); +- return () => clearTimeout(timer); +- }, [value]); +- return [value || kept, !value && Boolean(kept)]; +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; + return cache.has(sessionId); - } --function useLinger(value, ms) { -- const [kept, setKept] = (0, import_react.useState)(value); ++} +function countCards(state, sessionId, callIds, delta) { + const own = { ...state[sessionId] }; + for (const callId of callIds) { @@ -2048,232 +2080,6 @@ diff --git a/client.js b/client.js + else delete own[callId]; + } + return { ...state, [sessionId]: own }; -+} -+ -+// src/client/mark.js -+var import_react = 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_react.useEffect)(() => { -- if (value) { -- setKept(value); -+ if (!enabled) return void 0; -+ let frame = 0; -+ let point = null; -+ const place = () => { -+ frame = 0; -+ const node = ref.current; -+ if (!node || !point) return; -+ const box = node.getBoundingClientRect(); -+ const clamp = (value) => Math.max(-1, Math.min(1, value)); -+ node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -+ node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -+ }; -+ const onMove = (event) => { -+ point = { x: event.clientX, y: event.clientY }; -+ if (!frame) frame = requestAnimationFrame(place); -+ }; -+ const onLeave = () => { -+ point = null; -+ ref.current?.style.setProperty("--bt-gx", "0"); -+ ref.current?.style.setProperty("--bt-gy", "0"); -+ }; -+ window.addEventListener("pointermove", onMove, { passive: true }); -+ document.documentElement.addEventListener("pointerleave", onLeave); -+ return () => { -+ cancelAnimationFrame(frame); -+ window.removeEventListener("pointermove", onMove); -+ document.documentElement.removeEventListener("pointerleave", onLeave); -+ }; -+ }, [enabled]); -+} -+var FIDGETS = ["hop", "sway", "stretch", "look"]; -+function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -+ const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); -+ const ref = (0, import_react.useRef)(null); -+ const maskId = (0, import_react.useRef)(""); -+ if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -+ const [poked, setPoked] = (0, import_react.useState)(false); -+ useGaze(ref, gaze); -+ const common = { -+ ref, -+ className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -+ "data-shape": look.image ? "image" : look.emoji ? "emoji" : 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_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -+ if (look.emoji) { -+ return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { -+ className: "bt-mark-emoji", -+ style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } -+ }, look.emoji)); -+ } -+ const box = shapeOf(look.shape).size; -+ return (0, import_react.createElement)("span", common, (0, import_react.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_react.createElement)( -+ "svg", -+ { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -+ (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -+ ); -+} -+var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); -+var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); -+function PersonMark({ size = 22, name }) { -+ const initial = personInitial(name); -+ if (initial) { -+ return (0, import_react.createElement)("span", { -+ className: "bt-person-mark bt-person-initial", -+ "data-initial": initial, -+ style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, -+ "aria-hidden": true -+ }, initial); -+ } -+ return (0, import_react.createElement)( -+ "span", -+ { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, -+ (0, import_react.createElement)( -+ "svg", -+ { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, -+ (0, import_react.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), -+ (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) -+ ) -+ ); -+} -+function AdminStar() { -+ return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); -+} -+function MainStar() { -+ return (0, import_react.createElement)( -+ "svg", -+ { viewBox: "0 0 20 20", "aria-hidden": true }, -+ (0, import_react.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 useSettled(state) { -+ const prev = (0, import_react.useRef)(state); -+ const [settled, setSettled] = (0, import_react.useState)(false); -+ (0, import_react.useEffect)(() => { -+ const was = prev.current; -+ prev.current = state; -+ if (state !== "idle") { -+ setSettled(false); - return void 0; - } -- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); -+ if (was === "idle" || was === "alert") return void 0; -+ setSettled(true); -+ const timer = setTimeout(() => setSettled(false), 1800); - return () => clearTimeout(timer); -- }, [value]); -- return [value || kept, !value && Boolean(kept)]; -+ }, [state]); -+ return settled; -+} -+function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -+ const settled = useSettled(state); -+ if (bot === void 0) return (0, import_react.createElement)(BotMark, { size }); -+ const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -+ return (0, import_react.createElement)( -+ "span", -+ { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -+ (0, import_react.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -+ badgeKind === "working" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -+ badgeKind === "alert" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -+ badgeKind === "admin" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react.createElement)(StarIcon)) : null, -+ badgeKind === "main" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react.createElement)(MainStar)) : null -+ ); -+} -+function clusterSeats(count, frame) { -+ if (count === 1) return [[0, 0, frame]]; -+ if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -+ if (count === 3) { -+ const size = frame * 5 / 9; -+ const rest = frame - size; -+ return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -+ } -+ const half = frame / 2; -+ return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+} -+function roomAvatarPeople(room2, roster) { -+ const members = (room2?.members ?? []).map((id) => rosterBot(roster, id)).filter(Boolean); -+ const seen = new Set(members.map((bot) => bot.id)); -+ const remotes = []; -+ for (const p of room2?.participants ?? []) { -+ if (p.leftSeq !== void 0) continue; -+ if (p.kind === "human") continue; -+ if (!p.remote && p.id && seen.has(p.id)) continue; -+ const known = !p.remote && p.id ? rosterBot(roster, p.id) : void 0; -+ if (known) { -+ if (!seen.has(known.id)) { -+ members.push(known); -+ seen.add(known.id); -+ } -+ continue; -+ } -+ const handle = p.handle || (p.remote ? p.id : ""); -+ if (!handle) continue; -+ if (members.some((bot) => bot.remote?.handle === handle || bot.handle === handle)) continue; -+ const key = `h:${handle}`; -+ if (seen.has(key)) continue; -+ seen.add(key); -+ remotes.push(lookBotForRemote({ -+ handle, -+ displayName: p.displayName, -+ name: p.displayName, -+ label: p.label, -+ color: p.color, -+ avatar: p.avatar -+ }, roster)); -+ } -+ if (room2?.origin !== "face-to-face" && members.length === 1 && remotes.length === 0) return [members[0], null]; -+ return [...members, ...remotes]; -+} -+function RoomAvatar({ room: room2, roster, size = 36 }) { -+ const people2 = clusterOrder(roomAvatarPeople(room2, roster)); -+ const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -+ if (people2.length === 0) return (0, import_react.createElement)(BotMark, { size }); -+ if (size <= 20 && people2.length > 1) { -+ return (0, import_react.createElement)( -+ "span", -+ { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -+ people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -+ people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null -+ ); -+ } -+ const seats = clusterSeats(Math.min(people2.length, 4), size); -+ const overflow = people2.length > 4 ? people2.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_react.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_react.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 = people2[index]; -+ return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -+ }) -+ ); } -// src/client/styles.js @@ -2376,87 +2182,7 @@ diff --git a/client.js b/client.js -.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} -.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} -.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} -+// src/client/room-invite-preview.js -+var INVITE_PREVIEW_NAME_MAX = 60; -+function stripInviteMarkers(text) { -+ let out = String(text ?? ""); -+ for (let previous; previous !== out; ) { -+ previous = out; -+ out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); -+ } -+ return out; -+} -+function cleanInviteName(value, fallback = "") { -+ return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; -+} -+function inviteMemberCount(value) { -+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN; -+ if (typeof value !== "string") return NaN; -+ const text = value.trim(); -+ if (!text) return NaN; -+ const count = Number(text); -+ return Number.isFinite(count) ? count : NaN; -+} -+function invitePreviewOf(seen) { -+ if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; -+ const name = cleanInviteName(seen.name); -+ const lead = cleanInviteName(seen.lead); -+ const members = inviteMemberCount(seen.members); -+ const maxMembers = inviteMemberCount(seen.maxMembers); -+ const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; -+ const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; -+ if (!name && !lead && !people2 && !expiresAt) return null; -+ return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; -+} -+function inviteCountdown(expiresAt, offsetMs = 0) { -+ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); -+ if (!Number.isFinite(at)) return "Open while the room is open"; -+ const left = Math.max(0, at - Date.now()); -+ if (left <= 0) return "This link has expired"; -+ const total = Math.max(1, Math.ceil(left / 1e3)); -+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; -+} -+function RoomInvitePreview({ h: h47, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { -+ if (!preview || typeof h47 !== "function") return null; -+ const at = `${kind}`; -+ const child = (name) => `${at}-${name}`; -+ const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); -+ return h47( -+ "div", -+ { className: "fs-preview", "data-flybot": at, role: "status" }, -+ h47("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), -+ preview.lead ? h47("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, -+ preview.people ? h47("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, -+ preview.expiresAt ? h47("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, -+ onJoin || onClear ? h47( -+ "div", -+ { className: "fs-preview-foot", "data-flybot": child("foot") }, -+ showClear && onClear ? h47("button", { -+ type: "button", -+ className: "fs-btn fs-btn-sm", -+ "data-flybot": child("clear"), -+ disabled: !!busy, -+ onClick: onClear -+ }, clearLabel) : null, -+ onJoin ? h47("button", { -+ type: "button", -+ className: "fs-btn fs-btn-sm fs-btn-primary", -+ "data-flybot": child("join"), -+ disabled: !!busy, -+ onClick: onJoin -+ }, joinLabel) : null -+ ) : null -+ ); -+} -+function inviteCountdownAt(expiresAt, offsetMs, now) { -+ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); -+ if (!Number.isFinite(at)) return "Open while the room is open"; -+ const left = Math.max(0, at - Number(now || 0)); -+ if (left <= 0) return "This link has expired"; -+ const total = Math.max(1, Math.ceil(left / 1e3)); -+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; -+} - +- -.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} -.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} @@ -2492,32 +2218,7 @@ diff --git a/client.js b/client.js -.bt-connect-tip{position:fixed;box-sizing:border-box;width:max-content;max-width:260px;padding:8px 12px;border-radius:10px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 8px 24px -4px rgba(0,0,0,.14);color:var(--bt-ink);font-size:12px;line-height:17px;white-space:normal;text-align:center;z-index:70;transform-origin:bottom left;animation:bt-tip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -@keyframes bt-tip-in{from{opacity:0;transform:scale(.96);filter:blur(4px)}} -@media (prefers-reduced-motion:reduce){.bt-connect-tip{animation:none}} -+// src/client/approval-claims.js -+var claims = /* @__PURE__ */ new Map(); -+var claimListeners = /* @__PURE__ */ new Set(); -+var openChats = /* @__PURE__ */ new Map(); -+var changed = () => { -+ for (const fn of [...claimListeners]) fn(); -+}; -+function chatOpened(sessionId, delta) { -+ const count = (openChats.get(sessionId) ?? 0) + delta; -+ if (count > 0) openChats.set(sessionId, count); -+ else openChats.delete(sessionId); -+ changed(); -+} -+var claimedIds = () => Object.assign(new Set([...claims.values()].flatMap((ids) => [...ids])), { chats: new Set(openChats.keys()) }); -+function claimApprovals(owner, ids) { -+ if (!Array.isArray(ids) || ids.length === 0) claims.delete(owner); -+ else claims.set(owner, new Set(ids.map(String))); -+ changed(); -+} -+function onClaims(fn) { -+ claimListeners.add(fn); -+ return () => { -+ claimListeners.delete(fn); -+ }; -+} - +- -.bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} -.bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} -.bt-pill:hover{background:color-mix(in srgb,var(--bt-main),#777 9%)} @@ -2541,12 +2242,7 @@ diff --git a/client.js b/client.js -.bt-pill[aria-expanded=true] .bt-pill-go svg{transform:rotate(180deg)} -.bt-pill[data-memory=updated]:is(:hover,:focus-visible,[aria-expanded=true]) .bt-pill-go{width:0;margin-left:-8px;opacity:0;filter:blur(6px);transform:translateX(-6px)} -@media (prefers-reduced-motion:reduce){.bt-pill,.bt-pill-go,.bt-pill-go svg{transition:none!important}.bt-pill-go{filter:none}.bt-pill:active{transform:none}} -+// src/shared/approval-waiting.js -+var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); -+function isApprovalWaiting(status) { -+ return WAITING_STATUSES.includes(status); -+} - +- -.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} -.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} -.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} @@ -2567,26 +2263,7 @@ diff --git a/client.js b/client.js -.bt-group-name{font-size:12px;line-height:16px;font-weight:500;margin-left:42px} -.bt-x{border:0;background:none;color:var(--bt-ink-2);cursor:pointer;width:24px;height:24px;border-radius:6px;font-size:16px;line-height:24px;padding:0;transition:background-color .12s ease,color .12s ease,${spring(SPRING_TAP, "scale")}} -.bt-x:hover{background:var(--bt-hover);color:var(--bt-ink)} -+// src/shared/held-retry.js -+var TERMINAL = [ -+ // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError). -+ /no such remote bot/i, -+ /is not shared with your Bots/i, -+ /is no longer shared with you/i, -+ // Only chat.public is a share taken back (#971). capability_not_granted is Bot Social's generic -+ // "peer has not granted " code, so the bare code does not decide (#988). -+ /has not granted chat\.public/i, -+ // The codes, when a transport passes the code instead of its sentence. -+ /bot_not_shared/i, -+ /bot_not_found/i -+]; -+var canRetry = (record) => { -+ if (!record) return true; -+ if (record.retry === false || record.noRetry === true) return false; -+ const said = `${record.error ?? ""} ${record.code ?? ""}`; -+ return !TERMINAL.some((reason) => reason.test(said)); -+}; - +- -.bt-q{margin:0 auto 8px;width:100%;max-width:var(--dsh-chat-content-width,748px);display:flex;flex-direction:column;gap:12px;--bt-q-edge:color-mix(in srgb,var(--bt-ink) 10%,transparent);--bt-q-rule:color-mix(in srgb,var(--bt-ink) 8%,transparent);--bt-q-rule-2:color-mix(in srgb,var(--bt-ink) 5%,transparent);background:var(--bt-card);box-shadow:0 1px 3px rgba(20,20,20,.04),inset 0 0 0 1px var(--bt-q-edge);border-radius:16px;padding:14px 16px;box-sizing:border-box;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} -body[data-ds-dark-theme] .bt-q{--bt-q-edge:rgba(255,255,255,.16);--bt-q-rule:rgba(255,255,255,.12);--bt-q-rule-2:rgba(255,255,255,.08)} -@keyframes bt-q-in{from{transform:translateY(10px) scale(.97)}} @@ -2659,300 +2336,7 @@ diff --git a/client.js b/client.js -.bt-q-ownrow .bt-q-input{color:var(--bt-ink)} -.bt-q-raised .bt-q-foot{padding:0 8px 8px} -@media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} -+// src/client/flybot-slots.js -+var import_react2 = require("react"); -+var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); -+function registry2() { -+ const slots = registry(); -+ globalThis.__flybot__.call ??= call2; -+ globalThis.__flybot__.host ??= call; -+ globalThis.__flybot__.t ??= t; -+ globalThis.__flybot__.canRetry ??= canRetry; -+ globalThis.__flybot__.BotMark ??= BotMark; -+ globalThis.__flybot__.BotAvatar ??= BotAvatar; -+ globalThis.__flybot__.lookOf ??= lookOf; -+ globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; -+ globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; -+ globalThis.__flybot__.inviteCountdown ??= inviteCountdown; -+ globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel }; -+ return slots; -+} -+var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); -+function useSlot(name) { -+ const slots = registry2(); -+ const [, setVersion] = (0, import_react2.useState)(0); -+ (0, import_react2.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); -+ return slots.entries(name); -+} -+var SlotBoundary = class extends import_react2.Component { -+ constructor(props) { -+ super(props); -+ this.state = { failed: false }; -+ } -+ static getDerivedStateFromError() { -+ return { failed: true }; -+ } -+ componentDidCatch(error) { -+ console.error(`flybot: ${this.props.id} failed`, error); -+ } -+ render() { -+ return this.state.failed ? this.props.fallback ?? null : this.props.children; -+ } -+}; -+function SlotIcon({ id, icon: icon3, fallback }) { -+ return (0, import_react2.createElement)(SlotBoundary, { id, fallback }, (0, import_react2.createElement)(icon3)); -+} -+function Safe({ entry, props }) { -+ return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); -+} -+function useSettingsPages() { -+ return useSlot("flybot.settings.page"); -+} -+function SettingsPageSlot({ entry, props }) { -+ return (0, import_react2.createElement)(Safe, { entry, props }); -+} -+function useSidebarStructure() { -+ return useSlot("flybot.sidebar.structure")[0]; -+} -+function FlyBotSections({ roster, actions, renderBot }) { -+ const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); -+ const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); -+ const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); -+ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) -+ ))); -+} -+function useClaimed() { -+ const [claimed, setClaimed] = (0, import_react2.useState)(claimedIds); -+ (0, import_react2.useEffect)(() => { -+ const fn = () => setClaimed(claimedIds()); -+ const off = onClaims(fn); -+ fn(); -+ return off; -+ }, []); -+ return claimed; -+} -+function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { -+ const entries2 = useSlot("flybot.session.dock"); -+ (0, import_react2.useEffect)(() => { -+ if (!sessionId) return void 0; -+ chatOpened(sessionId, 1); -+ return () => chatOpened(sessionId, -1); -+ }, [sessionId]); -+ const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; -+ if (entries2.length === 0) return null; -+ const room2 = roster?.roomsById?.[sessionId]; -+ const bot = room2 ? void 0 : roster?.byId?.[sessionId]; -+ const blank3 = session?.blank === true && session?.promptAttempted !== true; -+ const botRoom = blank3 && bot !== void 0 && !bot.remote ? bot : null; -+ return (0, import_react2.createElement)( -+ import_react2.Fragment, -+ null, -+ botRoom ? (0, import_react2.createElement)(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, -+ entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) -+ )) -+ ); -+} -+function BotChatStrip({ bot, roster, actions, sessionId }) { -+ const main = isMainOf(roster, bot.id); -+ const subtitle = [bot.role, main ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"); -+ return (0, import_react2.createElement)( -+ "div", -+ { className: "bt-chat-strip", "data-flybot": "bot-chat-strip" }, -+ (0, import_react2.createElement)(BotAvatar, { bot, size: 44, main, live: false }), -+ (0, import_react2.createElement)( -+ "div", -+ { className: "bt-chat-strip-who" }, -+ (0, import_react2.createElement)("span", { className: "bt-chat-strip-name", title: bot.name }, bot.name), -+ (0, import_react2.createElement)("span", { className: "bt-chat-strip-sub" }, subtitle), -+ (0, import_react2.createElement)("span", { className: "bt-chat-strip-empty" }, t("No messages yet. Say something to {name}.", { name: bot.name })) -+ ), -+ (0, import_react2.createElement)("button", { -+ type: "button", -+ className: "bt-soft bt-soft-sm bt-chat-strip-go", -+ onClick: () => actions?.toggleDetails ? actions.toggleDetails(sessionId) : void 0 -+ }, t("Details")) -+ ); -+} -+function FlyBotRoomOverview({ room: room2, roster, actions }) { -+ const entries2 = useSlot("flybot.room.overview"); -+ if (entries2.length === 0) return null; -+ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) -+ ))); -+} -+function FlyBotRoomMembers({ room: room2, roster, actions }) { -+ const entries2 = useSlot("flybot.room.members"); -+ if (entries2.length === 0) return null; -+ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-room-members", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2, host: call } }) -+ ))); -+} -+function FlyBotAccountHeader({ actions }) { -+ const entries2 = useSlot("flybot.account.header"); -+ if (entries2.length === 0) return null; -+ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-account-header", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { actions, call: call2 } }) -+ ))); -+} -+function FlyBotDetails({ bot, roster, actions, wrap }) { -+ const entries2 = useSlot("flybot.bot.details"); -+ if (entries2.length === 0) return null; -+ const list = entries2.map((entry) => (0, import_react2.createElement)( -+ "div", -+ { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, -+ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, actions, call: call2 } }) -+ )); -+ return wrap ? wrap(list) : (0, import_react2.createElement)(import_react2.Fragment, null, list); -+} -+var POLL_MS = 5e3; -+var POINTER_DELAY_MS = 1200; -+var handleOf = (ref) => typeof ref === "object" && ref ? ref.handle : typeof ref === "string" && ref.startsWith("@") ? ref : void 0; -+var friendChatOf = (approval, roster) => { -+ const handle = handleOf(approval.request?.to); -+ return handle ? (roster?.bots ?? []).find((item) => item.remote && String(item.remote.handle ?? "").toLowerCase() === handle.toLowerCase()) : void 0; -+}; -+var chatOf = (approval, roster) => approval.request?.meta?.roomId ?? approval.request?.meta?.replyIn?.sessionId ?? friendChatOf(approval, roster)?.id ?? (typeof approval.request?.from === "string" ? approval.request.from : void 0); -+function recipientName(approval, roster) { -+ const handle = handleOf(approval.request?.to); -+ const bot = friendChatOf(approval, roster); -+ return approval.recipientName || (bot ? friendBotOwner(bot) : "") || approval.targetName || handle || String(approval.request?.to ?? ""); -+} -+function FlyBotApprovals({ useRoster, actions } = {}) { -+ const entries2 = useSlot("flybot.approvals"); -+ const docks = useSlot("flybot.session.dock"); -+ const claimed = useClaimed(); -+ const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; -+ const [pending, setPending] = (0, import_react2.useState)([]); -+ const [busy, setBusy] = (0, import_react2.useState)(""); -+ const [error, setError] = (0, import_react2.useState)(""); -+ const [shown, setShown] = (0, import_react2.useState)(""); -+ const refresh = () => call2("approvals", { status: [...WAITING_STATUSES, "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => { -+ }); -+ (0, import_react2.useEffect)(() => { -+ void refresh(); -+ const timer = setInterval(refresh, POLL_MS); -+ return () => clearInterval(timer); -+ }, []); -+ const policies = new Set(docks.map((entry) => entry.policy).filter(Boolean)); -+ const open = pending.filter((approval) => !claimed.has(approval.id)); -+ const cards = open.filter((approval) => !policies.has(approval.by)); -+ const inOpenChat = (approval) => { -+ const chat = chatOf(approval, roster); -+ return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); -+ }; -+ const away = open.filter((approval) => policies.has(approval.by) && isApprovalWaiting(approval.status) && !inOpenChat(approval)); -+ const awayKey = away.map((approval) => approval.id).join(","); -+ (0, import_react2.useEffect)(() => { -+ if (!awayKey) { -+ setShown(""); -+ return void 0; -+ } -+ const timer = setTimeout(() => setShown(awayKey), POINTER_DELAY_MS); -+ return () => clearTimeout(timer); -+ }, [awayKey]); -+ const pointers = shown === awayKey && awayKey ? away : []; -+ if (cards.length === 0 && pointers.length === 0) return null; -+ const decide = async (id, decision) => { -+ setBusy(id); -+ setError(""); -+ try { -+ await call2("decide", { id, decision }); -+ } catch (failure) { -+ setError(failure?.message ?? String(failure)); -+ } finally { -+ setBusy(""); -+ await refresh(); -+ } -+ }; -+ const byChat = /* @__PURE__ */ new Map(); -+ for (const approval of pointers) { -+ const key = chatOf(approval, roster) ?? approval.id; -+ byChat.set(key, [...byChat.get(key) ?? [], approval]); -+ } -+ return (0, import_react2.createElement)( -+ import_react2.Fragment, -+ null, -+ byChat.size > 0 ? (0, import_react2.createElement)( -+ "div", -+ { className: "bt-flybot-pointers", role: "status", "aria-label": t("Messages waiting for your approval") }, -+ [...byChat].map(([chat, list]) => (0, import_react2.createElement)( -+ "div", -+ { key: chat, className: "bt-flybot-pointer" }, -+ (0, import_react2.createElement)( -+ "span", -+ { className: "bt-flybot-pointer-text" }, -+ t("{from} → {to}", { from: list[0].senderName ?? t("A Bot"), to: recipientName(list[0], roster) }), -+ list.length > 1 ? ` ${t("+{count} more", { count: list.length - 1 })}` : "" -+ ), -+ (0, import_react2.createElement)("span", { className: "bt-flybot-pointer-note" }, t("Waiting for approval")), -+ typeof actions?.openSession === "function" && chatOf(list[0], roster) ? (0, import_react2.createElement)("button", { type: "button", onClick: () => actions.openSession(chatOf(list[0], roster)) }, t("Review")) : null -+ )) -+ ) : null, -+ cards.length > 0 ? (0, import_react2.createElement)( -+ "div", -+ { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") }, -+ cards.map((approval) => { -+ const custom = entries2.find((entry) => entry.inline !== true && (entry.policy === void 0 || entry.policy === approval.by)); -+ if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); -+ const failed = approval.status === "failed"; -+ const waiting = isApprovalWaiting(approval.status); -+ return (0, import_react2.createElement)( -+ "div", -+ { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, -+ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval")), -+ (0, import_react2.createElement)( -+ "div", -+ { className: "bt-flybot-approval-origin" }, -+ t("{from} → {to}", { from: approval.senderName ?? t("A Bot"), to: recipientName(approval, roster) }), -+ approval.request.hop > 0 ? ` · ${t("delegated")}` : "" -+ ), -+ failed && approval.error ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-error" }, approval.error) : null, -+ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), -+ approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, -+ (0, import_react2.createElement)( -+ "div", -+ { className: "bt-flybot-approval-actions" }, -+ // #968: `noRetry` (the dock's spelling of a terminal failure) and `retry: false` (the bubble's) -+ // are one rule, asked here through the shared predicate. -+ failed && canRetry(approval) ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, -+ waiting ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) : null, -+ waiting ? (0, import_react2.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")) : null -+ ) -+ ); -+ }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) -+ ) : null -+ ); -+} -+var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; -+var FLYBOT_CSS = ` -+.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} -+/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row -+ above the composer naming the chat, so a blank column is never nameless. */ -+.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} -+.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} -+.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} -+.bt-chat-strip-go{flex:none} -+@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} -+.bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} -+.bt-flybot-session-dock .fs-hold{margin-left:auto} -+.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } -+.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } -+.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } - +- -.bt-newchat{flex:1;display:flex;flex-direction:column;min-width:0;position:relative;animation:bt-veil-in .18s ease-out} -.bt-to{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:48px;padding:6px 12px;border-bottom:1px solid var(--bt-line);font-size:14px;box-sizing:border-box} -.bt-to-label{color:var(--bt-ink-2)} @@ -2996,57 +2380,329 @@ diff --git a/client.js b/client.js -.bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} -.bt-send:disabled{opacity:.35;cursor:default} -.bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} -+.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } -+/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw -+ the separator: #818's "no border" held for the component but not for the box around it. Keyed on -+ emptiness, not on a section id, so the next null-rendering section is covered too. */ -+.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } -+.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } -+.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } -+.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } -+.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } -+.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } -+.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } -+.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } -+.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } -+.bt-flybot-approve { font-weight: 600; } -+.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } -+.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } -+.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } -+.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } -+.bt-held-head { font-weight: 600; margin-bottom: 2px; } -+.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } -+.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } -+.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } -+.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } -+.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } -+.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } -+.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } -+.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } -+`; ++// src/client/mark.js ++var import_react = 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_react.useEffect)(() => { ++ if (!enabled) return void 0; ++ let frame = 0; ++ let point = null; ++ const place = () => { ++ frame = 0; ++ const node = ref.current; ++ if (!node || !point) return; ++ const box = node.getBoundingClientRect(); ++ const clamp = (value) => Math.max(-1, Math.min(1, value)); ++ node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); ++ node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); ++ }; ++ const onMove = (event) => { ++ point = { x: event.clientX, y: event.clientY }; ++ if (!frame) frame = requestAnimationFrame(place); ++ }; ++ const onLeave = () => { ++ point = null; ++ ref.current?.style.setProperty("--bt-gx", "0"); ++ ref.current?.style.setProperty("--bt-gy", "0"); ++ }; ++ window.addEventListener("pointermove", onMove, { passive: true }); ++ document.documentElement.addEventListener("pointerleave", onLeave); ++ return () => { ++ cancelAnimationFrame(frame); ++ window.removeEventListener("pointermove", onMove); ++ document.documentElement.removeEventListener("pointerleave", onLeave); ++ }; ++ }, [enabled]); ++} ++var FIDGETS = ["hop", "sway", "stretch", "look"]; ++function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { ++ const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); ++ const ref = (0, import_react.useRef)(null); ++ const maskId = (0, import_react.useRef)(""); ++ if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; ++ const [poked, setPoked] = (0, import_react.useState)(false); ++ useGaze(ref, gaze); ++ const common = { ++ ref, ++ className: poked ? "bt-mark bt-mark-poke" : "bt-mark", ++ "data-shape": look.image ? "image" : look.emoji ? "emoji" : 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_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); ++ if (look.emoji) { ++ return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { ++ className: "bt-mark-emoji", ++ style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } ++ }, look.emoji)); ++ } ++ const box = shapeOf(look.shape).size; ++ return (0, import_react.createElement)("span", common, (0, import_react.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_react.createElement)( ++ "svg", ++ { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, ++ (0, import_react.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++ ); ++} ++var personInitial = (name) => (Array.from(String(name ?? "").trim())[0] ?? "").toUpperCase(); ++var personColor = (name) => defaultColor(`person:${String(name ?? "").trim().toLowerCase()}`); ++function PersonMark({ size = 22, name }) { ++ const initial = personInitial(name); ++ if (initial) { ++ return (0, import_react.createElement)("span", { ++ className: "bt-person-mark bt-person-initial", ++ "data-initial": initial, ++ style: { width: size, height: size, background: inkFill(personColor(name)), fontSize: Math.max(9, Math.round(size * 0.46)) }, ++ "aria-hidden": true ++ }, initial); ++ } ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-person-mark", style: { width: size, height: size }, "aria-hidden": true }, ++ (0, import_react.createElement)( ++ "svg", ++ { width: size * 0.64, height: size * 0.64, viewBox: "0 0 24 24", fill: "currentColor" }, ++ (0, import_react.createElement)("circle", { cx: 12, cy: 8.5, r: 4.2 }), ++ (0, import_react.createElement)("path", { d: "M3.5 21c0-4.7 3.8-7.6 8.5-7.6s8.5 2.9 8.5 7.6z" }) ++ ) ++ ); ++} ++function AdminStar() { ++ return (0, import_react.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react.createElement)(StarIcon, { size: 7 })); ++} ++function MainStar() { ++ return (0, import_react.createElement)( ++ "svg", ++ { viewBox: "0 0 20 20", "aria-hidden": true }, ++ (0, import_react.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 useSettled(state) { ++ const prev = (0, import_react.useRef)(state); ++ const [settled, setSettled] = (0, import_react.useState)(false); ++ (0, import_react.useEffect)(() => { ++ const was = prev.current; ++ prev.current = state; ++ if (state !== "idle") { ++ setSettled(false); ++ return void 0; ++ } ++ if (was === "idle" || was === "alert") return void 0; ++ setSettled(true); ++ const timer = setTimeout(() => setSettled(false), 1800); ++ return () => clearTimeout(timer); ++ }, [state]); ++ return settled; ++} ++function 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_react.createElement)(BotMark, { size }); ++ const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, ++ (0, import_react.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), ++ badgeKind === "working" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, ++ badgeKind === "alert" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, ++ badgeKind === "admin" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react.createElement)(StarIcon)) : null, ++ badgeKind === "main" ? (0, import_react.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react.createElement)(MainStar)) : null ++ ); ++} ++function clusterSeats(count, frame) { ++ if (count === 1) return [[0, 0, frame]]; ++ if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; ++ if (count === 3) { ++ const size = frame * 5 / 9; ++ const rest = frame - size; ++ return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; ++ } ++ const half = frame / 2; ++ return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++} ++function roomAvatarPeople(room2, roster) { ++ const members = (room2?.members ?? []).map((id) => rosterBot(roster, id)).filter(Boolean); ++ const seen = new Set(members.map((bot) => bot.id)); ++ const remotes = []; ++ for (const p of room2?.participants ?? []) { ++ if (p.leftSeq !== void 0) continue; ++ if (p.kind === "human") continue; ++ if (!p.remote && p.id && seen.has(p.id)) continue; ++ const known = !p.remote && p.id ? rosterBot(roster, p.id) : void 0; ++ if (known) { ++ if (!seen.has(known.id)) { ++ members.push(known); ++ seen.add(known.id); ++ } ++ continue; ++ } ++ const handle = p.handle || (p.remote ? p.id : ""); ++ if (!handle) continue; ++ if (members.some((bot) => bot.remote?.handle === handle || bot.handle === handle)) continue; ++ const key = `h:${handle}`; ++ if (seen.has(key)) continue; ++ seen.add(key); ++ remotes.push(lookBotForRemote({ ++ handle, ++ displayName: p.displayName, ++ name: p.displayName, ++ label: p.label, ++ color: p.color, ++ avatar: p.avatar ++ }, roster)); ++ } ++ if (room2?.origin !== "face-to-face" && members.length === 1 && remotes.length === 0) return [members[0], null]; ++ return [...members, ...remotes]; ++} ++function RoomAvatar({ room: room2, roster, size = 36 }) { ++ const people2 = clusterOrder(roomAvatarPeople(room2, roster)); ++ const seat = (bot, seatSize, key) => bot ? (0, import_react.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); ++ if (people2.length === 0) return (0, import_react.createElement)(BotMark, { size }); ++ if (size <= 20 && people2.length > 1) { ++ return (0, import_react.createElement)( ++ "span", ++ { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, ++ people2.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), ++ people2.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people2.length - 3}`) : null ++ ); ++ } ++ const seats = clusterSeats(Math.min(people2.length, 4), size); ++ const overflow = people2.length > 4 ? people2.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_react.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_react.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 = people2[index]; ++ return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ }) ++ ); ++} + +-.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} +-.bt-dialog .bt-head{height:62px} +-.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} +-.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} +-.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} +-.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} +-@keyframes bt-sheet-in{from{transform:translateY(24px)}} +-@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} +-.bt-exchange .bt-dialog{position:relative} +-.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} +-.bt-exchange .bt-tools{display:none} +-.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} +-.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} +-.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} +-@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} +-@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} +-@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} +-.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} +-.bt-soft:hover{background:var(--bt-active)} +-.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} ++// src/client/room-invite-preview.js ++var INVITE_PREVIEW_NAME_MAX = 60; ++function stripInviteMarkers(text) { ++ let out = String(text ?? ""); ++ for (let previous; previous !== out; ) { ++ previous = out; ++ out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); ++ } ++ return out; ++} ++function cleanInviteName(value, fallback = "") { ++ return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; ++} ++function inviteMemberCount(value) { ++ if (typeof value === "number") return Number.isFinite(value) ? value : NaN; ++ if (typeof value !== "string") return NaN; ++ const text = value.trim(); ++ if (!text) return NaN; ++ const count = Number(text); ++ return Number.isFinite(count) ? count : NaN; ++} ++function invitePreviewOf(seen) { ++ if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; ++ const name = cleanInviteName(seen.name); ++ const lead = cleanInviteName(seen.lead); ++ const members = inviteMemberCount(seen.members); ++ const maxMembers = inviteMemberCount(seen.maxMembers); ++ const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; ++ const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; ++ if (!name && !lead && !people2 && !expiresAt) return null; ++ return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; ++} ++function inviteCountdown(expiresAt, offsetMs = 0) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Date.now()); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} ++function RoomInvitePreview({ h: h47, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h47 !== "function") return null; ++ const at = `${kind}`; ++ const child = (name) => `${at}-${name}`; ++ const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); ++ return h47( ++ "div", ++ { className: "fs-preview", "data-flybot": at, role: "status" }, ++ h47("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h47("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h47("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h47("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h47( ++ "div", ++ { className: "fs-preview-foot", "data-flybot": child("foot") }, ++ showClear && onClear ? h47("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm", ++ "data-flybot": child("clear"), ++ disabled: !!busy, ++ onClick: onClear ++ }, clearLabel) : null, ++ onJoin ? h47("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm fs-btn-primary", ++ "data-flybot": child("join"), ++ disabled: !!busy, ++ onClick: onJoin ++ }, joinLabel) : null ++ ) : null ++ ); ++} ++function inviteCountdownAt(expiresAt, offsetMs, now) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Number(now || 0)); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} --.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} --.bt-dialog .bt-head{height:62px} --.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} --.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} --.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} --.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} --@keyframes bt-sheet-in{from{transform:translateY(24px)}} --@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} --.bt-exchange .bt-dialog{position:relative} --.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} --.bt-exchange .bt-tools{display:none} --.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} --.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} --.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} --@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} --@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} --.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} --.bt-soft:hover{background:var(--bt-active)} --.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} -- -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} -.bt-panel[data-instant]{transition:none} @@ -3081,7 +2737,32 @@ diff --git a/client.js b/client.js -.bt-kv{display:flex;justify-content:space-between;gap:8px;font-size:12px;line-height:20px;color:var(--bt-ink-2)} -.bt-kv span:last-child{color:var(--bt-ink);font-variant-numeric:tabular-nums} -.bt-danger{color:#e02135} -- ++// src/client/approval-claims.js ++var claims = /* @__PURE__ */ new Map(); ++var claimListeners = /* @__PURE__ */ new Set(); ++var openChats = /* @__PURE__ */ new Map(); ++var changed = () => { ++ for (const fn of [...claimListeners]) fn(); ++}; ++function chatOpened(sessionId, delta) { ++ const count = (openChats.get(sessionId) ?? 0) + delta; ++ if (count > 0) openChats.set(sessionId, count); ++ else openChats.delete(sessionId); ++ changed(); ++} ++var claimedIds = () => Object.assign(new Set([...claims.values()].flatMap((ids) => [...ids])), { chats: new Set(openChats.keys()) }); ++function claimApprovals(owner, ids) { ++ if (!Array.isArray(ids) || ids.length === 0) claims.delete(owner); ++ else claims.set(owner, new Set(ids.map(String))); ++ changed(); ++} ++function onClaims(fn) { ++ claimListeners.add(fn); ++ return () => { ++ claimListeners.delete(fn); ++ }; ++} + -/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */ -.bt-field{display:flex;flex-direction:column;gap:6px} -.bt-field-label{font-size:13px;line-height:18px;font-weight:500} @@ -3132,7 +2813,12 @@ diff --git a/client.js b/client.js -.bt-drawer-model .bt-note{margin-top:6px} -.bt-drawer-permission{display:flex;flex-direction:column} -.bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} -- ++// src/shared/approval-waiting.js ++var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); ++function isApprovalWaiting(status) { ++ return WAITING_STATUSES.includes(status); ++} + -/* Settings dialog: a 198px page list beside the page. */ -.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -.bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} @@ -3192,7 +2878,26 @@ diff --git a/client.js b/client.js -.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0} -.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px} -.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none} -- ++// src/shared/held-retry.js ++var TERMINAL = [ ++ // A friend's Bot that is gone or no longer shared (plugins/social/src/social.js: botGoneError / notSharedError). ++ /no such remote bot/i, ++ /is not shared with your Bots/i, ++ /is no longer shared with you/i, ++ // Only chat.public is a share taken back (#971). capability_not_granted is Bot Social's generic ++ // "peer has not granted " code, so the bare code does not decide (#988). ++ /has not granted chat\.public/i, ++ // The codes, when a transport passes the code instead of its sentence. ++ /bot_not_shared/i, ++ /bot_not_found/i ++]; ++var canRetry = (record) => { ++ if (!record) return true; ++ if (record.retry === false || record.noRetry === true) return false; ++ const said = `${record.error ?? ""} ${record.code ?? ""}`; ++ return !TERMINAL.some((reason) => reason.test(said)); ++}; + -/* Settings → Connectors. */ -.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0} @@ -3207,32 +2912,299 @@ diff --git a/client.js b/client.js -.bt-connector-form-row{display:flex;align-items:center;gap:6px} -.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} -.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} -+// src/shared/failure-copy.js -+var providerCode = (text) => { -+ const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); -+ if (json) return json[1]; -+ const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); -+ return plain ? plain[1] : ""; ++// src/client/flybot-slots.js ++var import_react2 = require("react"); ++var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); ++function registry2() { ++ const slots = registry(); ++ globalThis.__flybot__.call ??= call2; ++ globalThis.__flybot__.host ??= call; ++ globalThis.__flybot__.t ??= t; ++ globalThis.__flybot__.canRetry ??= canRetry; ++ globalThis.__flybot__.BotMark ??= BotMark; ++ globalThis.__flybot__.BotAvatar ??= BotAvatar; ++ globalThis.__flybot__.lookOf ??= lookOf; ++ globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; ++ globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; ++ globalThis.__flybot__.inviteCountdown ??= inviteCountdown; ++ globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner, remoteMentionLabel }; ++ return slots; ++} ++var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options }); ++function useSlot(name) { ++ const slots = registry2(); ++ const [, setVersion] = (0, import_react2.useState)(0); ++ (0, import_react2.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]); ++ return slots.entries(name); ++} ++var SlotBoundary = class extends import_react2.Component { ++ constructor(props) { ++ super(props); ++ this.state = { failed: false }; ++ } ++ static getDerivedStateFromError() { ++ return { failed: true }; ++ } ++ componentDidCatch(error) { ++ console.error(`flybot: ${this.props.id} failed`, error); ++ } ++ render() { ++ return this.state.failed ? this.props.fallback ?? null : this.props.children; ++ } +}; -+var KNOWN = { -+ UnsupportedModel: "This model does not support that request. Pick another model in the Bot's settings.", -+ ModelNotFound: "The provider does not know this model. Check the model name in the Bot's settings.", -+ ContextLengthExceeded: "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.", -+ InvalidRequest: "The model service refused the request. Check the model and the endpoint in the Bot's settings.", -+ InsufficientQuota: "The account is out of quota.", -+ RateLimitExceeded: "Too many requests right now. Try again in a moment." ++function SlotIcon({ id, icon: icon3, fallback }) { ++ return (0, import_react2.createElement)(SlotBoundary, { id, fallback }, (0, import_react2.createElement)(icon3)); ++} ++function Safe({ entry, props }) { ++ return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); ++} ++function useSettingsPages() { ++ return useSlot("flybot.settings.page"); ++} ++function SettingsPageSlot({ entry, props }) { ++ return (0, import_react2.createElement)(Safe, { entry, props }); ++} ++function useSidebarStructure() { ++ return useSlot("flybot.sidebar.structure")[0]; ++} ++function FlyBotSections({ roster, actions, renderBot }) { ++ const shell = (actions.flybotSlotEntries?.("flybot.sidebar.section") ?? []).map((entry, index) => ({ id: entry.id ?? `slot-${index}`, order: entry.order ?? 0, component: entry.component })); ++ const entries2 = [...useSlot("flybot.sidebar.section"), ...shell].sort((a, b) => a.order - b.order); ++ const remoteChats = (roster.bots ?? []).filter((bot) => bot.remote && isListedRemoteChat(bot)); ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } }) ++ ))); ++} ++function useClaimed() { ++ const [claimed, setClaimed] = (0, import_react2.useState)(claimedIds); ++ (0, import_react2.useEffect)(() => { ++ const fn = () => setClaimed(claimedIds()); ++ const off = onClaims(fn); ++ fn(); ++ return off; ++ }, []); ++ return claimed; ++} ++function FlyBotSessionDock({ sessionId, useRoster, actions, session }) { ++ const entries2 = useSlot("flybot.session.dock"); ++ (0, import_react2.useEffect)(() => { ++ if (!sessionId) return void 0; ++ chatOpened(sessionId, 1); ++ return () => chatOpened(sessionId, -1); ++ }, [sessionId]); ++ const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; ++ if (entries2.length === 0) return null; ++ const room2 = roster?.roomsById?.[sessionId]; ++ const bot = room2 ? void 0 : roster?.byId?.[sessionId]; ++ const blank3 = session?.blank === true && session?.promptAttempted !== true; ++ const botRoom = blank3 && bot !== void 0 && !bot.remote ? bot : null; ++ return (0, import_react2.createElement)( ++ import_react2.Fragment, ++ null, ++ botRoom ? (0, import_react2.createElement)(BotChatStrip, { bot: botRoom, roster, actions, sessionId }) : null, ++ entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2, t, blank: blank3 === true, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) ++ )) ++ ); ++} ++function BotChatStrip({ bot, roster, actions, sessionId }) { ++ const main = isMainOf(roster, bot.id); ++ const subtitle = [bot.role, main ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"); ++ return (0, import_react2.createElement)( ++ "div", ++ { className: "bt-chat-strip", "data-flybot": "bot-chat-strip" }, ++ (0, import_react2.createElement)(BotAvatar, { bot, size: 44, main, live: false }), ++ (0, import_react2.createElement)( ++ "div", ++ { className: "bt-chat-strip-who" }, ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-name", title: bot.name }, bot.name), ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-sub" }, subtitle), ++ (0, import_react2.createElement)("span", { className: "bt-chat-strip-empty" }, t("No messages yet. Say something to {name}.", { name: bot.name })) ++ ), ++ (0, import_react2.createElement)("button", { ++ type: "button", ++ className: "bt-soft bt-soft-sm bt-chat-strip-go", ++ onClick: () => actions?.toggleDetails ? actions.toggleDetails(sessionId) : void 0 ++ }, t("Details")) ++ ); ++} ++function FlyBotRoomOverview({ room: room2, roster, actions }) { ++ const entries2 = useSlot("flybot.room.overview"); ++ if (entries2.length === 0) return null; ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) ++ ))); ++} ++function FlyBotRoomMembers({ room: room2, roster, actions }) { ++ const entries2 = useSlot("flybot.room.members"); ++ if (entries2.length === 0) return null; ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-room-members", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2, host: call } }) ++ ))); ++} ++function FlyBotAccountHeader({ actions }) { ++ const entries2 = useSlot("flybot.account.header"); ++ if (entries2.length === 0) return null; ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-account-header", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { actions, call: call2 } }) ++ ))); ++} ++function FlyBotDetails({ bot, roster, actions, wrap }) { ++ const entries2 = useSlot("flybot.bot.details"); ++ if (entries2.length === 0) return null; ++ const list = entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, actions, call: call2 } }) ++ )); ++ return wrap ? wrap(list) : (0, import_react2.createElement)(import_react2.Fragment, null, list); ++} ++var POLL_MS = 5e3; ++var POINTER_DELAY_MS = 1200; ++var handleOf = (ref) => typeof ref === "object" && ref ? ref.handle : typeof ref === "string" && ref.startsWith("@") ? ref : void 0; ++var friendChatOf = (approval, roster) => { ++ const handle = handleOf(approval.request?.to); ++ return handle ? (roster?.bots ?? []).find((item) => item.remote && String(item.remote.handle ?? "").toLowerCase() === handle.toLowerCase()) : void 0; +}; -+var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); -+function failureSentence(raw, translate = (key) => key) { -+ const text = String(raw ?? "").trim(); -+ if (text === "") return text; -+ const key = KNOWN[providerCode(text)]; -+ if (key === void 0) return text; -+ const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); -+ if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); -+ const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; -+ return fill2(translate(withFeature, { feature }), { feature }); ++var chatOf = (approval, roster) => approval.request?.meta?.roomId ?? approval.request?.meta?.replyIn?.sessionId ?? friendChatOf(approval, roster)?.id ?? (typeof approval.request?.from === "string" ? approval.request.from : void 0); ++function recipientName(approval, roster) { ++ const handle = handleOf(approval.request?.to); ++ const bot = friendChatOf(approval, roster); ++ return approval.recipientName || (bot ? friendBotOwner(bot) : "") || approval.targetName || handle || String(approval.request?.to ?? ""); ++} ++function FlyBotApprovals({ useRoster, actions } = {}) { ++ const entries2 = useSlot("flybot.approvals"); ++ const docks = useSlot("flybot.session.dock"); ++ const claimed = useClaimed(); ++ const roster = typeof useRoster === "function" ? useRoster((value) => value) : void 0; ++ const [pending, setPending] = (0, import_react2.useState)([]); ++ const [busy, setBusy] = (0, import_react2.useState)(""); ++ const [error, setError] = (0, import_react2.useState)(""); ++ const [shown, setShown] = (0, import_react2.useState)(""); ++ const refresh = () => call2("approvals", { status: [...WAITING_STATUSES, "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => { ++ }); ++ (0, import_react2.useEffect)(() => { ++ void refresh(); ++ const timer = setInterval(refresh, POLL_MS); ++ return () => clearInterval(timer); ++ }, []); ++ const policies = new Set(docks.map((entry) => entry.policy).filter(Boolean)); ++ const open = pending.filter((approval) => !claimed.has(approval.id)); ++ const cards = open.filter((approval) => !policies.has(approval.by)); ++ const inOpenChat = (approval) => { ++ const chat = chatOf(approval, roster); ++ return Boolean(chat) && (claimed.chats?.has(chat) === true || claimed.chats?.has(roster?.byId?.[chat]?.sessionId) === true); ++ }; ++ const away = open.filter((approval) => policies.has(approval.by) && isApprovalWaiting(approval.status) && !inOpenChat(approval)); ++ const awayKey = away.map((approval) => approval.id).join(","); ++ (0, import_react2.useEffect)(() => { ++ if (!awayKey) { ++ setShown(""); ++ return void 0; ++ } ++ const timer = setTimeout(() => setShown(awayKey), POINTER_DELAY_MS); ++ return () => clearTimeout(timer); ++ }, [awayKey]); ++ const pointers = shown === awayKey && awayKey ? away : []; ++ if (cards.length === 0 && pointers.length === 0) return null; ++ const decide = async (id, decision) => { ++ setBusy(id); ++ setError(""); ++ try { ++ await call2("decide", { id, decision }); ++ } catch (failure) { ++ setError(failure?.message ?? String(failure)); ++ } finally { ++ setBusy(""); ++ await refresh(); ++ } ++ }; ++ const byChat = /* @__PURE__ */ new Map(); ++ for (const approval of pointers) { ++ const key = chatOf(approval, roster) ?? approval.id; ++ byChat.set(key, [...byChat.get(key) ?? [], approval]); ++ } ++ return (0, import_react2.createElement)( ++ import_react2.Fragment, ++ null, ++ byChat.size > 0 ? (0, import_react2.createElement)( ++ "div", ++ { className: "bt-flybot-pointers", role: "status", "aria-label": t("Messages waiting for your approval") }, ++ [...byChat].map(([chat, list]) => (0, import_react2.createElement)( ++ "div", ++ { key: chat, className: "bt-flybot-pointer" }, ++ (0, import_react2.createElement)( ++ "span", ++ { className: "bt-flybot-pointer-text" }, ++ t("{from} → {to}", { from: list[0].senderName ?? t("A Bot"), to: recipientName(list[0], roster) }), ++ list.length > 1 ? ` ${t("+{count} more", { count: list.length - 1 })}` : "" ++ ), ++ (0, import_react2.createElement)("span", { className: "bt-flybot-pointer-note" }, t("Waiting for approval")), ++ typeof actions?.openSession === "function" && chatOf(list[0], roster) ? (0, import_react2.createElement)("button", { type: "button", onClick: () => actions.openSession(chatOf(list[0], roster)) }, t("Review")) : null ++ )) ++ ) : null, ++ cards.length > 0 ? (0, import_react2.createElement)( ++ "div", ++ { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") }, ++ cards.map((approval) => { ++ const custom = entries2.find((entry) => entry.inline !== true && (entry.policy === void 0 || entry.policy === approval.by)); ++ if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); ++ const failed = approval.status === "failed"; ++ const waiting = isApprovalWaiting(approval.status); ++ return (0, import_react2.createElement)( ++ "div", ++ { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, ++ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval")), ++ (0, import_react2.createElement)( ++ "div", ++ { className: "bt-flybot-approval-origin" }, ++ t("{from} → {to}", { from: approval.senderName ?? t("A Bot"), to: recipientName(approval, roster) }), ++ approval.request.hop > 0 ? ` · ${t("delegated")}` : "" ++ ), ++ failed && approval.error ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-error" }, approval.error) : null, ++ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), ++ approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, ++ (0, import_react2.createElement)( ++ "div", ++ { className: "bt-flybot-approval-actions" }, ++ // #968: `noRetry` (the dock's spelling of a terminal failure) and `retry: false` (the bubble's) ++ // are one rule, asked here through the shared predicate. ++ failed && canRetry(approval) ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null, ++ waiting ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject")) : null, ++ waiting ? (0, import_react2.createElement)("button", { type: "button", className: "bt-flybot-approve", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")) : null ++ ) ++ ); ++ }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) ++ ) : null ++ ); +} ++var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; ++var FLYBOT_CSS = ` ++.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} ++/* #1347: a Bot chat's own strip before its first turn, the same shape a group's uses (#998): one row ++ above the composer naming the chat, so a blank column is never nameless. */ ++.bt-chat-strip{display:flex;align-items:center;gap:12px;width:100%;max-width:min(992px,100%);margin:0 auto 10px;padding:10px 12px;box-sizing:border-box;border:.5px solid var(--bt-line-2);border-radius:14px;background:var(--bt-card,var(--bt-main));animation:bt-rise .18s ease-out} ++.bt-chat-strip-who{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:2px} ++.bt-chat-strip-name{font-size:15px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-chat-strip-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} ++.bt-chat-strip-empty{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere} ++.bt-chat-strip-go{flex:none} ++@media (prefers-reduced-motion:reduce){.bt-chat-strip{animation:none}} ++.bt-flybot-session-dock [data-flybot=chat-approvals]{align-items:flex-end;padding-bottom:4px} ++.bt-flybot-session-dock .fs-hold{margin-left:auto} ++.bt-flybot-approval-files { margin: 0 0 8px; padding-left: 18px; font-size: 12px; } ++.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } ++.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } -/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the - shell offers them and CSS-module name suffixes elsewhere. */ @@ -3242,18 +3214,34 @@ diff --git a/client.js b/client.js -[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important} -[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important} -[class*="_widthHandle"]{display:none!important} -+// src/shared/turn-failures.js -+var FAILURE_LINES_SHOWN = 3; -+var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { -+ const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; -+ return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ -+ id: `turn-failure-${entry.id}`, -+ // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the -+ // same fact in words that name what to change (#1347). -+ text: t2("Could not run: {error}", { error: entry.failed === void 0 ? t2("the model call failed") : failureSentence(entry.failed, t2) }), -+ ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} -+ })); -+}; ++.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } ++/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw ++ the separator: #818's "no border" held for the component but not for the box around it. Keyed on ++ emptiness, not on a section id, so the next null-rendering section is covered too. */ ++.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } ++.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } ++.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } ++.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } ++.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } ++.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } ++.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } ++.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } ++.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } ++.bt-flybot-approve { font-weight: 600; } ++.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } ++.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } ++.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } ++.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } ++.bt-held-head { font-weight: 600; margin-bottom: 2px; } ++.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } ++.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } ++.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } ++.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } ++.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } ++.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } ++.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } ++.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } ++`; -.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out} -.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center} @@ -3314,6 +3302,47 @@ diff --git a/client.js b/client.js -button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} -.bt-you-wrap{display:inline-flex;padding:0} -.bt-connect-foot{width:100%;height:36px} ++// src/shared/failure-copy.js ++var providerCode = (text) => { ++ const json = /"code"\s*:\s*"([A-Za-z0-9_.-]+)"/.exec(text); ++ if (json) return json[1]; ++ const plain = /^([A-Za-z][A-Za-z0-9_.-]{2,40}):\s/.exec(text); ++ return plain ? plain[1] : ""; ++}; ++var KNOWN = { ++ UnsupportedModel: "This model does not support that request. Pick another model in the Bot's settings.", ++ ModelNotFound: "The provider does not know this model. Check the model name in the Bot's settings.", ++ ContextLengthExceeded: "The conversation is longer than this model can read. Condense it, or pick a model with a larger window.", ++ InvalidRequest: "The model service refused the request. Check the model and the endpoint in the Bot's settings.", ++ InsufficientQuota: "The account is out of quota.", ++ RateLimitExceeded: "Too many requests right now. Try again in a moment." ++}; ++var fill2 = (text, params) => params === void 0 ? text : String(text).replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match); ++function failureSentence(raw, translate = (key) => key) { ++ const text = String(raw ?? "").trim(); ++ if (text === "") return text; ++ const key = KNOWN[providerCode(text)]; ++ if (key === void 0) return text; ++ const feature = /does not support\s+([a-z][a-z0-9 _-]{2,40})/i.exec(text)?.[1]?.trim(); ++ if (key !== KNOWN.UnsupportedModel || feature === void 0) return fill2(translate(key), void 0); ++ const withFeature = "This model does not support {feature}. Pick another model in the Bot's settings."; ++ return fill2(translate(withFeature, { feature }), { feature }); ++} + +-.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} ++// src/shared/turn-failures.js ++var FAILURE_LINES_SHOWN = 3; ++var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { ++ const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; ++ return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ ++ id: `turn-failure-${entry.id}`, ++ // The stored text can be a provider's JSON body cut mid-sentence; `failureSentence` says the ++ // same fact in words that name what to change (#1347). ++ text: t2("Could not run: {error}", { error: entry.failed === void 0 ? t2("the model call failed") : failureSentence(entry.failed, t2) }), ++ ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} ++ })); ++}; ++ +// src/client/turn-failures.js +var import_react3 = require("react"); +function TurnFailures({ sessionId, work }) { @@ -3343,11 +3372,7 @@ diff --git a/client.js b/client.js +.bt-turn-failure{display:flex;gap:6px;align-items:baseline;padding:6px 10px;border-radius:8px;font-size:12px;line-height:1.45;background:color-mix(in srgb,var(--bt-ink) 6%,transparent);color:color-mix(in srgb,var(--bt-ink) 72%,transparent)} +.bt-turn-failure-streak{opacity:.72} +`; - --.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} --.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} --.bt-menu button:hover{background:var(--bt-hover)} --.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} ++ +// src/client/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; +var THEMES = { @@ -4339,12 +4364,10 @@ diff --git a/client.js b/client.js +.bt-connect-foot{width:100%;height:36px} + +.bt-menu{position:fixed;pointer-events:auto;z-index:130;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} -+.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} -+.bt-menu button:hover{background:var(--bt-hover)} -+.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} - .bt-menu .bt-danger{color:#c21d2e} - .bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px} - .bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in} + .bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} + .bt-menu button:hover{background:var(--bt-hover)} + .bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} +@@ -2390,7 +2390,7 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side .bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} .bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} @@ -9296,7 +9319,7 @@ diff --git a/client.js b/client.js + const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0; + if (entry) options.push(`${workPhrase(entry.tool)}…`); + } -+ if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))); ++ if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))); + return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? ""; +} +function toolCalls(card, { roster, work }) { @@ -9408,7 +9431,7 @@ diff --git a/client.js b/client.js + const bot = ownBot(roster, entry.botId); + if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; + const chat = entry.roomId ?? bot.id; -+ const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ""; ++ const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : ""; + needs.push({ + id: `turn:${entry.id}`, + kind: "turn", @@ -9451,7 +9474,7 @@ diff --git a/client.js b/client.js + const part = currentPart(bot); + const at = sessions.byId[part]?.updatedAt; + if (!fresh(at)) continue; -+ const text = previewOf(sessions, part); ++ const text = previewOf(sessions, part, [], [], botNames(roster)); + if (text === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); } @@ -10851,7 +10874,7 @@ diff --git a/client.js b/client.js const id = bot.id; const part = currentPart(bot); - const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); -+ const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster)); ++ const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster)); const running = useSessionStatus((map) => map.get(part)?.running === true); const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active; @@ -11001,7 +11024,7 @@ diff --git a/client.js b/client.js const id = room2.id; - const fallback = useSessions((list) => previewOf(list, id)); - const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; -+ const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster)); ++ const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster)); + const preview = nameMentions(visibleText(room2.preview), roster) || fallback; const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; @@ -12307,10 +12330,13 @@ 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"]); -@@ -6121,232 +6121,8 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - }; - } - +@@ -6118,235 +6118,11 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { + if (!spokeAt) return "waiting"; + if (level < quiet) quietSince ||= now; + return quietSince && now - quietSince >= quietMs ? "done" : "speech"; +- }; +-} +- -// src/client/overlay-hooks.js -var import_react11 = require("react"); -var escapeLayers = []; @@ -12533,8 +12559,9 @@ diff --git a/client.js b/client.js - ) - ) - ); --} -- ++ }; + } + // src/client/model-menu.js -var import_react13 = require("react"); +var import_react19 = require("react"); @@ -14607,7 +14634,7 @@ diff --git a/client.js b/client.js const isMainBot = isMainOf(roster, bot.id); const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; const isTile = roster.mainBotId === bot.id; -@@ -8011,149 +8011,810 @@ function BotSettingsForm({ bot, roster, actions, run }) { +@@ -8011,149 +8011,812 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; @@ -15275,6 +15302,7 @@ diff --git a/client.js b/client.js +// src/client/palette.js +var import_react29 = require("react"); +var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); ++var paletteText = (raw, names) => readableLine(String(raw ?? ""), names); +var PALETTE_EXIT_MS = 160; +function CommandPalette({ start, token, roster, actions, useSessions }) { + const [query, setQuery] = (0, import_react29.useState)(start ?? ""); @@ -15288,6 +15316,7 @@ diff --git a/client.js b/client.js + }; + useEscape(close); + const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)]; ++ const names = botNames(roster); + const found = useSessions((list) => { + if (wanted.length < 2) return "[]"; + const hits = []; @@ -15299,7 +15328,7 @@ diff --git a/client.js b/client.js + const entry = outline[index2]; + if (hidden.includes(entry.turn)) continue; + for (const raw of [entry.response, entry.prompt]) { -+ const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim(); ++ const text = paletteText(raw, names); + const at = text.toLowerCase().indexOf(wanted); + if (at < 0) continue; + const from = Math.max(0, at - 24); diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index 0febe9a34..beafd6545 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -14,7 +14,7 @@ import { workPhrase } from './cells.js' import { elapsedLabel } from './activity-row.js' import { useEscape } from './overlay-hooks.js' import { alwaysChoices } from './member-status.js' -import { currentPart, previewOf, relativeShort } from './text.js' +import { botNames, currentPart, previewOf, relativeShort } from './text.js' import { hostText, t } from './i18n.js' import { failureSentence } from '../shared/failure-copy.js' @@ -129,7 +129,7 @@ function latestActivity(card, { roster, work, sessions }) { const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined if (entry) options.push(`${workPhrase(entry.tool)}…`) } - if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat))) + if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster))) return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? '' } @@ -230,7 +230,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date const bot = ownBot(roster, entry.botId) if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue const chat = entry.roomId ?? bot.id - const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : '' + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : '' needs.push({ id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at, title: asked || t('The last turn did not finish'), reason: entry.failed, @@ -261,7 +261,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date const part = currentPart(bot) const at = sessions.byId[part]?.updatedAt if (!fresh(at)) continue - const text = previewOf(sessions, part) + const text = previewOf(sessions, part, [], [], botNames(roster)) if (text === '') continue done.push({ id: `chat:${bot.id}`, kind: 'chat', status: 'done', bot, at, title: text, place: '', chat: bot.id, ...people(roster, bot.id) }) } diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index 03e1f356c..71e50f932 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -42,12 +42,13 @@ test('ordinary text that merely mentions an arrow is left alone', () => { }) test('the sidebar preview drops the trace too', () => { + const names = ['Chief', '助手'] const list = { byId: { s: { projectionValues: { turnOutline: [{ turn: 2, prompt: '', response: 'Chief → 助手\n\nHi Chief!' }] } } } } - assert.equal(previewOf(list, 's'), 'Hi Chief!') + assert.equal(previewOf(list, 's', [], [], names), 'Hi Chief!') const flat = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Chief → 助手 Hi! Message received.' }] } } } } - assert.equal(previewOf(flat, 's'), 'Hi! Message received.') + assert.equal(previewOf(flat, 's', [], [], names), 'Hi! Message received.') const plain = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Use Settings → Models first' }] } } } } - assert.equal(previewOf(plain, 's'), 'Use Settings → Models first') + assert.equal(previewOf(plain, 's', [], [], names), 'Use Settings → Models first') }) test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), else a stable look from its handle", () => { diff --git a/patches/ds-bot/files/test/palette-preview.test.js b/patches/ds-bot/files/test/palette-preview.test.js new file mode 100644 index 000000000..75c1c3d80 --- /dev/null +++ b/patches/ds-bot/files/test/palette-preview.test.js @@ -0,0 +1,165 @@ +// The command palette's message rows are read by a person, like every other preview row (#1347): +// the header and a delegation trace are routing, and an untrusted fence is data. A search matches +// the words of the line, never the marker's attributes. +// +// The invariant each case pins is not a hand-written string but the live `previewOf` read of the same +// stored shape: a row is what a preview shows, or the palette has a second reading of stored text. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { botNames, previewOf, readableLine } from '../src/client/text.js' + +const { outputFiles } = await build({ + stdin: { contents: "export { CommandPalette } from './src/client/palette.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)) }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat() } }) +const react = { + createElement: h, Fragment: Symbol('F'), + useEffect() {}, useLayoutEffect() {}, + useState: (value) => [typeof value === 'function' ? value() : value, () => {}], + useRef: (current) => ({ current }), +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false }, window: {}, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { FishLogo: () => null, MarkdownText: ({ text }) => text } + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { CommandPalette } = module.exports + +// The conversation's real Bot names: only these can make a prefix a delegation trace (#1408). +const BOTS = [ + { id: 's1', sessionId: 's1', name: 'Chief' }, + { id: 's2', sessionId: 's2', name: '助手' }, + { id: 's3', sessionId: 's3', name: 'Dev Lead' }, + { id: 's4', sessionId: 's4', name: 'Dev-1' }, + { id: 's5', sessionId: 's5', name: 'Product' }, +] +const ROSTER = { bots: BOTS, byId: Object.fromEntries(BOTS.map(bot => [bot.id, bot])), rooms: [], roomsById: {}, agents: [], exchangeTurns: {} } +const NAMES = botNames(ROSTER) + +// The same stored line as the Session outline holds it, so `previewOf` is asked the question a +// sidebar row asks. +const outlineOf = (raw) => ({ projectionsBySession: { s1: { values: { turnOutline: [{ turn: 1, prompt: raw }] } } }, byId: {} }) + +// What the palette's message section found for `query` over that session's outline: [[session, turn, row]]. +function hits(query, raw) { + let found + const useSessions = (selector) => { found = selector(outlineOf(raw)); return found } + CommandPalette({ start: query, token: 't', roster: ROSTER, actions: { closePalette() {}, openSession() {} }, useSessions }) + return JSON.parse(found) +} + +// One stored shape, one row: the rendered row text must be byte-for-byte what `previewOf` reads of the +// same line, and `readableLine(text, names)` must be the same read as `previewOf(list, id, …, names)`. +function rowIsPreview(query, raw, message) { + const expected = previewOf(outlineOf(raw), 's1', [], [], NAMES) + const found = hits(query, raw) + assert.equal(found.length, 1, `the words of "${message}" are found`) + assert.equal(found[0][2], expected, `the row is what previewOf reads for "${message}"`) + assert.equal(expected, readableLine(raw, NAMES), `readableLine(text, names) is previewOf(list, id) for "${message}"`) + return found[0][2] +} + +// The outline flattens a reply to one line, so a flattened trace can carry its own spacing (#1347). +test('#1347: a trace indented with spaces is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief → 助手 Hi there', 'a space-indented trace') +}) + +test('#1347: a trace split by tabs is read as previewOf reads it', () => { + rowIsPreview('hi', 'Chief\t→\t助手 Hi there', 'a tab-separated trace') +}) + +test('#1347: a trace with doubled spaces is read as previewOf reads it', () => { + rowIsPreview('hi', 'Chief → 助手 Hi there', 'a double-spaced trace') +}) + +test('#1347: a trace with mixed indentation is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief \t → \t 助手 Hi there', 'a mixed-whitespace trace') +}) + +test('#1347: a trace after a header is read as previewOf reads it', () => { + rowIsPreview('hi', '[Reply from 助手]\n Chief → 助手 Hi there', 'a header then an indented trace') +}) + +test('#1347: a wrapped multi-line line is read as previewOf reads it', () => { + rowIsPreview('hi', ' Chief → 助手 Hi, there\nsecond line', 'a wrapped line with a flattened trace') +}) + +test('#1347: a fence fragment after a trace is read as previewOf reads it', () => { + const row = rowIsPreview('hi', 'Chief\t→\t助手 Hi ', 'a trace then a fence fragment') + assert.doesNotMatch(row, /untrusted-message|from="/) + + // A complete wrap reads the same way: only the words, and the marker's own words find nothing. + const wrapped = '[Message from User (user@friend)]\n\nB踢后测4\n' + assert.equal(previewOf(outlineOf(wrapped), 's1', [], [], NAMES), readableLine(wrapped, NAMES)) + assert.equal(hits('b踢', wrapped)[0][2], readableLine(wrapped, NAMES)) + for (const attribute of ['from', 'user', 'untrusted']) assert.deepEqual(hits(attribute, wrapped), [], `"${attribute}" belongs to the marker, not the line`) +}) + +// #1408 shape 1: the fence is taken before the trace, so a trace the fence wrapped is still routing. +test('#1408: a trace inside a fence reads as its words', () => { + const raw = '\nChief → 助手\n\nHi there\n' + assert.equal(readableLine(raw, NAMES), 'Hi there') + assert.equal(previewOf(outlineOf(raw), 's1', [], [], NAMES), 'Hi there') +}) + +// #1408 shape 2: a name is what makes the prefix a trace — an ordinary arrow line keeps every word, +// and stays findable by any of them. +test('#1408: an ordinary arrow line is not a trace', () => { + const raw = '[Message from Chief]\nSettings → Models 页面' + const expected = readableLine(raw, NAMES) + assert.equal(expected, 'Settings → Models 页面') + for (const query of ['Settings', 'Models', '页面']) { + const found = hits(query, raw) + assert.equal(found.length, 1, `"${query}" is found`) + assert.equal(found[0][2], expected, `the row keeps its words for "${query}"`) + } + assert.equal(readableLine('a → b c', NAMES), 'a → b c') +}) + +// #1408 shape 2, the other edge: a real trace loses its routing, whole names only, and a clock on its +// tail is routing too — never content, and never a row of its own. +test('#1408: a known-name trace loses its routing, whole names only', () => { + const cases = [ + ['Chief → Product,03:16Z 审计。**defect 无**', '审计。**defect 无**'], + ['Chief → Product 03:16Z 审计', '审计'], + ['Chief → Dev Lead → Dev-1 点头', '点头'], + ['Chief → Dev Lead 点头', '点头'], + ['Dev Lead → Chief. Base note is up', 'Base note is up'], + ['Chief → 助手 Hi there', 'Hi there'], + ['Chief → Product,03:16Z', ''], + ['Dev → Lead 点头', 'Dev → Lead 点头'], + ['Chief → 助手们 Hi there', 'Chief → 助手们 Hi there'], + ] + for (const [raw, expected] of cases) assert.equal(readableLine(raw, NAMES), expected) +}) + +// Without names nothing that only looks like a trace is removed: a caller that has no roster reads +// the line as before (#1408). +test('#1408: no names, no trace removal', () => { + assert.equal(readableLine('Chief → 助手 Hi there'), 'Chief → 助手 Hi there') + assert.equal(readableLine('Settings → Models 页面'), 'Settings → Models 页面') +}) + +// An empty turn is no line at all: the walk keeps going to an older turn, as a sidebar row does. +// #1408 shape 3: the back-fill `previewOf` did before `break` made it unreachable. +test('previewOf falls back to an older turn when the newest turn is empty', () => { + const outline = (turns) => ({ projectionsBySession: { s1: { values: { turnOutline: turns } } } }) + const empty = outline([{ turn: 1, response: 'older reply', prompt: 'q1' }, { turn: 2, response: '', prompt: '' }]) + assert.equal(previewOf(empty, 's1'), 'older reply') + assert.equal(previewOf(empty, 's1', [], [], NAMES), 'older reply') + + // An unsent turn still shows its prompt; the fallback must not swallow it. + const unsent = outline([{ turn: 1, response: 'older reply', prompt: 'q1' }, { turn: 2, response: 'held reply', prompt: 'What is 2+2?' }]) + assert.equal(previewOf(unsent, 's1', [], [2]), 'What is 2+2?') +})