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?')
+})