From 5215fb820bf0d40c598479a8c3cce8293f8d1577 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 11:31:50 +0000 Subject: [PATCH] Keep a local Bot's color when a friend shares its name The @Name file chip was keyed only by display name, the roster index let a later chat replace a local Bot's id, and a group icon painted the friend's seat on top. A local Bot keeps its stored look; the friend's own row stays on the friend's color. Fixes #963 Co-authored-by: Danyang Chen --- changelog.d/963-local-chief-color.fixed.md | 1 + core/client.js | 1876 +++--- core/src/client/cells.js | 7 +- core/src/client/cluster-order.js | 12 + core/src/client/index.js | 20 +- core/src/client/mark.js | 15 +- core/src/client/mention-style.js | 37 + core/src/client/sources.js | 30 +- core/src/client/text.js | 5 +- core/test/local-look.test.js | 90 + docs/subsystems/core-client.md | 3 +- patches/ds-bot/0001-core-fixes.patch | 14 + patches/ds-bot/0003-flybot-api-client.patch | 50 +- patches/ds-bot/0005-social-seams.patch | 19 +- patches/ds-bot/0008-client-bundle.patch | 5495 +++++++++-------- patches/ds-bot/0012-room-membership.patch | 43 +- .../ds-bot/files/src/client/cluster-order.js | 12 + .../ds-bot/files/src/client/mention-style.js | 37 + patches/ds-bot/files/test/local-look.test.js | 90 + 19 files changed, 4435 insertions(+), 3421 deletions(-) create mode 100644 changelog.d/963-local-chief-color.fixed.md create mode 100644 core/src/client/cluster-order.js create mode 100644 core/src/client/mention-style.js create mode 100644 core/test/local-look.test.js create mode 100644 patches/ds-bot/files/src/client/cluster-order.js create mode 100644 patches/ds-bot/files/src/client/mention-style.js create mode 100644 patches/ds-bot/files/test/local-look.test.js diff --git a/changelog.d/963-local-chief-color.fixed.md b/changelog.d/963-local-chief-color.fixed.md new file mode 100644 index 000000000..5ad9f1474 --- /dev/null +++ b/changelog.d/963-local-chief-color.fixed.md @@ -0,0 +1 @@ +Your own Bot keeps its color after a friend with the same name is added. The @ mention, the group icon, and that Bot's messages stay on the local look; the friend's Bot stays on its own ([#963](https://github.com/ThinkFlowLab/FlyBot/issues/963)). diff --git a/core/client.js b/core/client.js index fc68c2bbe..bc8fc8b55 100644 --- a/core/client.js +++ b/core/client.js @@ -1963,988 +1963,1021 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); -// 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 })); +// src/client/cluster-order.js +function clusterOrder(people2) { + const list = Array.isArray(people2) ? people2 : []; + const remote = (bot) => Boolean(bot?.remote); + if (!list.some(remote)) return list; + const locals = list.filter((bot) => !remote(bot)); + const remotes = list.filter(remote); + if (list.length <= 4) return [...remotes, ...locals]; + return [...locals, ...remotes]; } -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" }) - ); + +// src/shared/mentions.js +var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; +var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; +var ADDRESS_BEFORE = /[a-z0-9_./]/i; +var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; +var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); +var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); +function nameProblem(name) { + const text = String(name ?? "").trim(); + if (text === "") return "A Bot needs a name"; + if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; + if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; + return void 0; } -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; +function referenceSpans(text, labels = []) { + const spans = []; + for (const label of [...labels].sort((a, b) => b.length - a.length)) { + const mention = `@${label}`; + for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { + const end = at + mention.length; + if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; + spans.push({ start: at, end, label }); + break; } - 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 - ); + } + return spans.sort((a, b) => a.start - b.start); } -function clusterSeats(count, frame) { - if (count === 1) return [[0, 0, frame]]; - if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; - if (count === 3) { - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function mentionSpans(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ + ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), + ...referenceSpans(value, references) + ]; + const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); + const spans = []; + for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { + if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; + const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); + const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word)); + if (!bot && !all) continue; + const end = at + 1 + (bot ? bot.name.length : all.length); + spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); + at = end - 1; } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + return spans; } -function roomAvatarPeople(room2, roster) { - const members = (room2?.members ?? []).map((id) => roster?.byId?.[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; - if (!p.remote && p.id && roster?.byId?.[p.id]) { - if (!seen.has(p.id)) { - members.push(roster.byId[p.id]); - seen.add(p.id); - } +function unmatchedMentions(text, bots, { references = [] } = {}) { + const value = String(text ?? ""); + const skip = [ + ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), + ...referenceSpans(value, references) + ]; + const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); + const missing = []; + for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { + if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; + const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); + const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); + if (bot || all) { + at += 1 + (bot ? bot.name.length : all.length) - 1; 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)); + const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; + if (word) missing.push(`@${word}`); } - if (members.length === 1 && remotes.length === 0) return [members[0], null]; - return [...members, ...remotes]; + return missing; } -function RoomAvatar({ room: room2, roster, size = 36 }) { - const people2 = 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 - ); + +// src/client/text.js +var MENTION_ORIGIN = "https://bots.local/"; +function linkMentions(text, bots, selfId) { + if (!text || !text.includes("@")) return text; + const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); + let out = ""; + let cursor = 0; + for (const span of mentionSpans(text, ordered)) { + if (!span.bot || span.bot.id === selfId) continue; + out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; + cursor = span.end; } - 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)); - }) - ); + return cursor === 0 ? text : out + text.slice(cursor); } - -// 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 mentionTarget(event) { + const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; + return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; } -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 nameMentions(text, roster) { + const value = String(text ?? ""); + if (!value.includes("@") || roster === null || typeof roster !== "object") return value; + const names = /* @__PURE__ */ new Map(); + for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { + const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; + if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); + } + const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); + if (handles.length === 0) return value; + const source = handles.map((handle) => handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); + return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, "gi"), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`); } -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 clock(ms) { + return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); } -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 dayLabel(ms) { + const date = new Date(ms); + const today = /* @__PURE__ */ new Date(); + const sameDay = date.toDateString() === today.toDateString(); + return sameDay ? t("Today {time}", { time: clock(ms) }) : `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric" })} ${clock(ms)}`; } -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 relativeShort(ms) { + const diff = Math.max(0, Date.now() - ms); + if (diff < 6e4) return t("just now"); + if (diff < 36e5) return t("{n} min", { n: Math.floor(diff / 6e4) }); + if (diff < 864e5) return t("{n} h", { n: Math.floor(diff / 36e5) }); + if (diff < 30 * 864e5) return t("{n} d", { n: Math.floor(diff / 864e5) }); + return new Date(ms).toLocaleDateString(dateLocale(), { month: "short", day: "numeric" }); } -function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { - if (!preview || typeof h42 !== "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 h42( - "div", - { className: "fs-preview", "data-flybot": at, role: "status" }, - h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), - preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, - preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, - preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, - onJoin || onClear ? h42( - "div", - { className: "fs-preview-foot", "data-flybot": child("foot") }, - showClear && onClear ? h42("button", { - type: "button", - className: "fs-btn fs-btn-sm", - "data-flybot": child("clear"), - disabled: !!busy, - onClick: onClear - }, clearLabel) : null, - onJoin ? h42("button", { - type: "button", - className: "fs-btn fs-btn-sm fs-btn-primary", - "data-flybot": child("join"), - disabled: !!busy, - onClick: onJoin - }, joinLabel) : null - ) : null - ); +var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); +var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; +var currentPart = (bot) => bot.sessionId ?? bot.id; +function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { + const bot = last ? roster.byId[last.botId] : void 0; + if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); + if (bot && stay) return last.sessionId; + const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; + if (held) return last.sessionId; + const main = roster.byId[mainId]; + return main ? currentPart(main) : mainId; } -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`; +function reminderPrompts(text) { + const one = /^reminder_prompt_json: (.*)$/m.exec(text); + const batch = /^reminders_json: (.*)$/m.exec(text); + try { + const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; + return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); + } catch { + return []; + } } - -// 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 stripHeader = (text) => text.replace(HEADER, ""); +var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; +function stripTrace(text) { + if (typeof text !== "string") return text; + const line2 = text.match(TRACE)?.[0]; + if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; + return text.slice(line2.length).replace(/^\s+/, ""); } -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 latestMemoryChange(news, ids) { + const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; + return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); } -function onClaims(fn) { - claimListeners.add(fn); - return () => { - claimListeners.delete(fn); +function summaryBlocks(text) { + const blocks = []; + let paragraph = []; + const flush = () => { + if (paragraph.length > 0) blocks.push({ kind: "p", text: paragraph.join(" ") }); + paragraph = []; }; -} - -// src/shared/approval-waiting.js -var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); -function isApprovalWaiting(status) { - return WAITING_STATUSES.includes(status); -} - -// 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__.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 }; - 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; + for (const raw of String(text ?? "").split("\n")) { + const line2 = raw.trim().replace(/\*\*|__/g, ""); + const heading = /^#{1,6}\s+(.+)$/.exec(line2); + if (heading) { + flush(); + blocks.push({ kind: "h", text: heading[1].trim() }); + } else if (line2 === "") { + flush(); + } else { + paragraph.push(line2.replace(/^[-*•]\s+/, "")); + } } -}; -function SlotIcon({ id, icon: icon3, fallback }) { - return (0, import_react2.createElement)(SlotBoundary, { id, fallback }, (0, import_react2.createElement)(icon3)); + flush(); + return blocks; } -function Safe({ entry, props }) { - return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); +function typedBy(roster, sessionId, turn) { + if (sessionId === void 0 || turn === void 0) return void 0; + const name = roster.remoteTypedTurns?.[sessionId]?.[turn]; + return typeof name === "string" && name ? name : void 0; } -function useSettingsPages() { - return useSlot("flybot.settings.page"); +function isHiddenTurn(roster, sessionId, turn) { + if (sessionId === void 0 || turn === void 0) return false; + return (roster.exchangeTurns[sessionId] ?? []).includes(turn); } -function SettingsPageSlot({ entry, props }) { - return (0, import_react2.createElement)(Safe, { entry, props }); +var toolName = (root) => root.name || root.call?.name; +var pluginTools = /* @__PURE__ */ new Set(); +function setPluginTools(names) { + pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); } -function useSidebarStructure() { - return useSlot("flybot.sidebar.structure")[0]; +var isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); +var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; +function toolArgs(root) { + if (typeof root.args?.text === "function") { + return { text: (key) => root.args.text(key) ?? void 0, value: (key) => typeof root.args.value === "function" ? root.args.value(key) : void 0 }; + } + let parsed; + const object = () => { + if (parsed === void 0) { + try { + parsed = JSON.parse(root.argsRaw ?? root.call?.argsRaw ?? ""); + } catch { + parsed = null; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) parsed = null; + } + return parsed; + }; + return { + text: (key) => { + const value = object()?.[key]; + return typeof value === "string" ? value : void 0; + }, + value: (key) => object()?.[key] + }; } -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 } }) - ))); +var RELAY_ROLES = /* @__PURE__ */ new Set(["request", "reply", "stopped"]); +var isRelayContext = (data) => { + const source = data?.source; + if (source === null || typeof source !== "object" || source.kind !== "bot" || source.handoff === true) return false; + if (source.deferred === true || source.resumed === true || source.retried === true) return false; + return RELAY_ROLES.has(source.role) && typeof source.senderSessionId === "string"; +}; +var stepShows = (step) => (step?.blocks ?? []).some((block) => { + if (block === void 0 || block.kind === "tool-call") return false; + return block.kind === "text" || block.kind === "reasoning" ? String(block.text ?? "").trim() !== "" : true; +}); +var stepHasReasoning = (step) => (step?.blocks ?? []).some((block) => block?.kind === "reasoning" && String(block.text ?? "").trim() !== ""); +var relayPart = (data, groupPart) => groupPart === void 0 || (stepHasReasoning(data) ? groupPart === "reasoning" : groupPart === "response"); +function relaysBefore(contexts, steps, step) { + const index = steps.findIndex((entry) => entry?.step === step?.step); + if (index === -1) return []; + let prev = -Infinity; + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + if (stepShows(steps[cursor])) { + prev = steps[cursor].time; + break; + } + } + return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); } -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 commRunOf(order, peers, turn, hiddenTurns = []) { + const turns = []; + for (const entry of order) { + const peer = peers[entry]; + const hidden = hiddenTurns.includes(entry); + if (hidden && peer === void 0) continue; + turns.push({ turn: entry, peer, hidden }); + } + const at = turns.findIndex((entry) => entry.turn === turn); + if (at === -1 || turns[at].peer === void 0) return null; + const previous = turns[at - 1]; + if (previous?.peer !== void 0 && previous.hidden) return "folded"; + let count = 0; + const peerIds = []; + for (let index = at; index < turns.length; index += 1) { + const entry = turns[index]; + if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; + count += entry.hidden ? 2 : 1; + if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); + } + return { count, peerIds }; } -function FlyBotSessionDock({ sessionId, useRoster, actions }) { - 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]; - return (0, import_react2.createElement)(import_react2.Fragment, 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, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) - ))); +function heldReplyOf(bot, sessionId, turn) { + const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; + let found; + for (const reply of list) if (reply.sessionId === sessionId && String(reply.turn) === String(turn)) found = reply; + return found; } -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 } }) - ))); +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 = []) { + 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 stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); + } + return ""; } -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 } }) - ))); +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; + let cacheRead = 0; + let output = 0; + for (const id of ids) { + const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; + if (!usage) continue; + input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); + cacheRead += usage.cacheReadTokens ?? 0; + output += usage.outputTokens ?? 0; + } + if (input + output === 0) return ""; + const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; + return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } -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 faceRowTitle(title) { + const text = String(title ?? ""); + const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); + if (named) return `${named[2]} · ${named[1]}`; + return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; } -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); +function modelOf(bot, roster) { + const ref = bot.model ?? bot.appliedModel; + if (!ref) return void 0; + const served = (roster.models ?? []).find((model) => model.ref === ref); + const [provider, ...rest] = ref.split("/"); + return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; } -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 botRowTitle(name, model, wide) { + if (model) return `${name} · ${model}`; + return wide ? void 0 : name; } -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(); +function awayGroups(groups, roomsById) { + return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +} + +// src/client/sources.js +function createSource(initial) { + let value = initial; + const listeners2 = /* @__PURE__ */ new Set(); + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners2.add(listener); + return () => { + listeners2.delete(listener); + }; + }, + set(next) { + const resolved = typeof next === "function" ? next(value) : next; + if (resolved === value) return; + value = resolved; + for (const listener of [...listeners2]) listener(); } }; - const byChat = /* @__PURE__ */ new Map(); - for (const approval of pointers) { - const key = chatOf(approval, roster) ?? approval.id; - byChat.set(key, [...byChat.get(key) ?? [], approval]); +} +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); } - 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" }, - failed ? (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 - ); + const roomsById = {}; + for (const room2 of value.rooms) roomsById[room2.id] = room2; + const agentsById = {}; + const agentOf = {}; + for (const agent of value.agents ?? []) { + agentsById[agent.id] = agent; + for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; + } + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; +} +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 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 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 countCards(state, sessionId, callIds, delta) { + const own = { ...state[sessionId] }; + for (const callId of callIds) { + const count = (own[callId] ?? 0) + delta; + if (count > 0) own[callId] = count; + else delete own[callId]; + } + return { ...state, [sessionId]: own }; } -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} -.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-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/shared/mentions.js -var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; -var ADDRESS_BEFORE = /[a-z0-9_./]/i; -var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; -var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); -var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); -function nameProblem(name) { - const text = String(name ?? "").trim(); - if (text === "") return "A Bot needs a name"; - if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; - if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; - return void 0; +// 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]); } -function referenceSpans(text, labels = []) { - const spans = []; - for (const label of [...labels].sort((a, b) => b.length - a.length)) { - const mention = `@${label}`; - for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { - const end = at + mention.length; - if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; - spans.push({ start: at, end, label }); - break; +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; } - } - return spans.sort((a, b) => a.start - b.start); + if (was === "idle" || was === "alert") return void 0; + setSettled(true); + const timer = setTimeout(() => setSettled(false), 1800); + return () => clearTimeout(timer); + }, [state]); + return settled; } -function mentionSpans(text, bots, { references = [] } = {}) { - const value = String(text ?? ""); - const skip = [ - ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), - ...referenceSpans(value, references) - ]; - const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); - const spans = []; - for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { - if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; - const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); - const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word)); - if (!bot && !all) continue; - const end = at + 1 + (bot ? bot.name.length : all.length); - spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); - at = end - 1; +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]]; } - return spans; + const half = frame / 2; + return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; } -function unmatchedMentions(text, bots, { references = [] } = {}) { - const value = String(text ?? ""); - const skip = [ - ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), - ...referenceSpans(value, references) - ]; - const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); - const missing = []; - for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { - if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; - const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); - const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); - if (bot || all) { - at += 1 + (bot ? bot.name.length : all.length) - 1; +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 word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; - if (word) missing.push(`@${word}`); + 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)); } - return missing; + if (members.length === 1 && remotes.length === 0) return [members[0], null]; + return [...members, ...remotes]; } - -// src/client/text.js -var MENTION_ORIGIN = "https://bots.local/"; -function linkMentions(text, bots, selfId) { - if (!text || !text.includes("@")) return text; - let out = ""; - let cursor = 0; - for (const span of mentionSpans(text, bots)) { - if (!span.bot || span.bot.id === selfId) continue; - out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; - cursor = span.end; +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 + ); } - return cursor === 0 ? text : out + text.slice(cursor); -} -function mentionTarget(event) { - const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; - return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : 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)); + }) + ); } -function nameMentions(text, roster) { - const value = String(text ?? ""); - if (!value.includes("@") || roster === null || typeof roster !== "object") return value; - const names = /* @__PURE__ */ new Map(); - for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { - const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; - if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); + +// 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, ""); } - const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); - if (handles.length === 0) return value; - const source = handles.map((handle) => handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); - return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, "gi"), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`); + return out; } -function clock(ms) { - return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +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 dayLabel(ms) { - const date = new Date(ms); - const today = /* @__PURE__ */ new Date(); - const sameDay = date.toDateString() === today.toDateString(); - return sameDay ? t("Today {time}", { time: clock(ms) }) : `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric" })} ${clock(ms)}`; +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 relativeShort(ms) { - const diff = Math.max(0, Date.now() - ms); - if (diff < 6e4) return t("just now"); - if (diff < 36e5) return t("{n} min", { n: Math.floor(diff / 6e4) }); - if (diff < 864e5) return t("{n} h", { n: Math.floor(diff / 36e5) }); - if (diff < 30 * 864e5) return t("{n} d", { n: Math.floor(diff / 864e5) }); - return new Date(ms).toLocaleDateString(dateLocale(), { month: "short", day: "numeric" }); +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 }; } -var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); -var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; -var currentPart = (bot) => bot.sessionId ?? bot.id; -function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { - const bot = last ? roster.byId[last.botId] : void 0; - if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); - if (bot && stay) return last.sessionId; - const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; - if (held) return last.sessionId; - const main = roster.byId[mainId]; - return main ? currentPart(main) : mainId; +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 reminderPrompts(text) { - const one = /^reminder_prompt_json: (.*)$/m.exec(text); - const batch = /^reminders_json: (.*)$/m.exec(text); - try { - const prompts = [...one ? [JSON.parse(one[1])] : [], ...batch ? JSON.parse(batch[1]).map((entry) => entry.reminder_prompt) : []]; - return prompts.filter((prompt) => typeof prompt === "string" && prompt.trim() !== ""); - } catch { - return []; - } +function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h42 !== "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 h42( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, + h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h42( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, + showClear && onClear ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, + onJoin ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), + disabled: !!busy, + onClick: onJoin + }, joinLabel) : null + ) : null + ); } -var stripHeader = (text) => text.replace(HEADER, ""); -var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; -function stripTrace(text) { - if (typeof text !== "string") return text; - const line2 = text.match(TRACE)?.[0]; - if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; - return text.slice(line2.length).replace(/^\s+/, ""); +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`; } -function latestMemoryChange(news, ids) { - const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; - return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); + +// 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(); } -function summaryBlocks(text) { - const blocks = []; - let paragraph = []; - const flush = () => { - if (paragraph.length > 0) blocks.push({ kind: "p", text: paragraph.join(" ") }); - paragraph = []; +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); }; - for (const raw of String(text ?? "").split("\n")) { - const line2 = raw.trim().replace(/\*\*|__/g, ""); - const heading = /^#{1,6}\s+(.+)$/.exec(line2); - if (heading) { - flush(); - blocks.push({ kind: "h", text: heading[1].trim() }); - } else if (line2 === "") { - flush(); - } else { - paragraph.push(line2.replace(/^[-*•]\s+/, "")); - } - } - flush(); - return blocks; } -function typedBy(roster, sessionId, turn) { - if (sessionId === void 0 || turn === void 0) return void 0; - const name = roster.remoteTypedTurns?.[sessionId]?.[turn]; - return typeof name === "string" && name ? name : void 0; + +// src/shared/approval-waiting.js +var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); +function isApprovalWaiting(status) { + return WAITING_STATUSES.includes(status); } -function isHiddenTurn(roster, sessionId, turn) { - if (sessionId === void 0 || turn === void 0) return false; - return (roster.exchangeTurns[sessionId] ?? []).includes(turn); + +// 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__.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 }; + return slots; } -var toolName = (root) => root.name || root.call?.name; -var pluginTools = /* @__PURE__ */ new Set(); -function setPluginTools(names) { - pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); +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 isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); -var historyLoading = (snapshot) => snapshot?.openState === "cold" || snapshot?.openState === "loading"; -function toolArgs(root) { - if (typeof root.args?.text === "function") { - return { text: (key) => root.args.text(key) ?? void 0, value: (key) => typeof root.args.value === "function" ? root.args.value(key) : void 0 }; +var SlotBoundary = class extends import_react2.Component { + constructor(props) { + super(props); + this.state = { failed: false }; } - let parsed; - const object = () => { - if (parsed === void 0) { - try { - parsed = JSON.parse(root.argsRaw ?? root.call?.argsRaw ?? ""); - } catch { - parsed = null; - } - if (typeof parsed !== "object" || Array.isArray(parsed)) parsed = null; - } - return parsed; - }; - return { - text: (key) => { - const value = object()?.[key]; - return typeof value === "string" ? value : void 0; - }, - value: (key) => object()?.[key] - }; -} -var RELAY_ROLES = /* @__PURE__ */ new Set(["request", "reply", "stopped"]); -var isRelayContext = (data) => { - const source = data?.source; - if (source === null || typeof source !== "object" || source.kind !== "bot" || source.handoff === true) return false; - if (source.deferred === true || source.resumed === true || source.retried === true) return false; - return RELAY_ROLES.has(source.role) && typeof source.senderSessionId === "string"; -}; -var stepShows = (step) => (step?.blocks ?? []).some((block) => { - if (block === void 0 || block.kind === "tool-call") return false; - return block.kind === "text" || block.kind === "reasoning" ? String(block.text ?? "").trim() !== "" : true; -}); -var stepHasReasoning = (step) => (step?.blocks ?? []).some((block) => block?.kind === "reasoning" && String(block.text ?? "").trim() !== ""); -var relayPart = (data, groupPart) => groupPart === void 0 || (stepHasReasoning(data) ? groupPart === "reasoning" : groupPart === "response"); -function relaysBefore(contexts, steps, step) { - const index = steps.findIndex((entry) => entry?.step === step?.step); - if (index === -1) return []; - let prev = -Infinity; - for (let cursor = index - 1; cursor >= 0; cursor -= 1) { - if (stepShows(steps[cursor])) { - prev = steps[cursor].time; - break; - } + static getDerivedStateFromError() { + return { failed: true }; } - return (contexts ?? []).filter((data) => isRelayContext(data) && typeof data.time === "number" && data.time >= prev && data.time < step.time); -} -function commRunOf(order, peers, turn, hiddenTurns = []) { - const turns = []; - for (const entry of order) { - const peer = peers[entry]; - const hidden = hiddenTurns.includes(entry); - if (hidden && peer === void 0) continue; - turns.push({ turn: entry, peer, hidden }); + componentDidCatch(error) { + console.error(`flybot: ${this.props.id} failed`, error); } - const at = turns.findIndex((entry) => entry.turn === turn); - if (at === -1 || turns[at].peer === void 0) return null; - const previous = turns[at - 1]; - if (previous?.peer !== void 0 && previous.hidden) return "folded"; - let count = 0; - const peerIds = []; - for (let index = at; index < turns.length; index += 1) { - const entry = turns[index]; - if (entry.peer === void 0 || index > at && !turns[index - 1].hidden) break; - count += entry.hidden ? 2 : 1; - if (!peerIds.includes(entry.peer)) peerIds.push(entry.peer); + render() { + return this.state.failed ? this.props.fallback ?? null : this.props.children; } - return { count, peerIds }; +}; +function SlotIcon({ id, icon: icon3, fallback }) { + return (0, import_react2.createElement)(SlotBoundary, { id, fallback }, (0, import_react2.createElement)(icon3)); } -function heldReplyOf(bot, sessionId, turn) { - const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; - let found; - for (const reply of list) if (reply.sessionId === sessionId && String(reply.turn) === String(turn)) found = reply; - return found; +function Safe({ entry, props }) { + return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props)); } -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 = []) { - 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 stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; +function useSettingsPages() { + return useSlot("flybot.settings.page"); } -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; - let cacheRead = 0; - let output = 0; - for (const id of ids) { - const usage = list.projectionsBySession?.[id]?.values?.tokenUsage; - if (!usage) continue; - input += (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); - cacheRead += usage.cacheReadTokens ?? 0; - output += usage.outputTokens ?? 0; - } - if (input + output === 0) return ""; - const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; - return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); +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 }) { + 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]; + return (0, import_react2.createElement)(import_react2.Fragment, 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, claim: (ids) => claimApprovals(`${entry.id}:${sessionId}`, ids) } }) + ))); } -function faceRowTitle(title) { - const text = String(title ?? ""); - const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); - if (named) return `${named[2]} · ${named[1]}`; - return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; +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 modelOf(bot, roster) { - const ref = bot.model ?? bot.appliedModel; - if (!ref) return void 0; - const served = (roster.models ?? []).find((model) => model.ref === ref); - const [provider, ...rest] = ref.split("/"); - return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; +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 botRowTitle(name, model, wide) { - if (model) return `${name} · ${model}`; - return wide ? void 0 : name; +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 awayGroups(groups, roomsById) { - return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); +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); } - -// src/client/sources.js -function createSource(initial) { - let value = initial; - const listeners2 = /* @__PURE__ */ new Set(); - return { - getSnapshot: () => value, - subscribe: (listener) => { - listeners2.add(listener); - return () => { - listeners2.delete(listener); - }; - }, - set(next) { - const resolved = typeof next === "function" ? next(value) : next; - if (resolved === value) return; - value = resolved; - for (const listener of [...listeners2]) listener(); +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(); } }; -} -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 = {}; - for (const bot of value.bots) { - for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; - } - const roomsById = {}; - for (const room2 of value.rooms) roomsById[room2.id] = room2; - const agentsById = {}; - const agentOf = {}; - for (const agent of value.agents ?? []) { - agentsById[agent.id] = agent; - for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; - } - return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -} -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 countCards(state, sessionId, callIds, delta) { - const own = { ...state[sessionId] }; - for (const callId of callIds) { - const count = (own[callId] ?? 0) + delta; - if (count > 0) own[callId] = count; - else delete own[callId]; + 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 { ...state, [sessionId]: own }; + 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" }, + failed ? (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} +.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-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/themes.js var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -3051,6 +3084,33 @@ function registerTheme(id, theme) { } var allThemes = () => ({ ...THEMES, ...customThemes }); +// src/client/mention-style.js +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null; + return String(bot?.name ?? "").toLowerCase(); +} +function mentionCss(bots) { + const list = Array.isArray(bots) ? bots : []; + const owner = /* @__PURE__ */ new Map(); + for (const bot of list) { + const key = chipKey(bot); + if (key === null) continue; + const prev = owner.get(key); + if (prev && !prev.remote && bot?.remote) continue; + owner.set(key, bot); + } + return list.map((bot) => { + const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; + const key = chipKey(bot); + const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; + const look = lookOf(bot); + const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; + const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; + const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); +} + // src/client/motion.js var import_react3 = require("react"); var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -6222,7 +6282,9 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi "div", { className: "bt-group-replies" }, replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; + const stored = roster.byId[reply.botId]; + const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; + const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; if (reply.kind === "user") { const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; if (reply.other) { @@ -6405,7 +6467,7 @@ function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const root = node.data.root; const turn = node.location?.turn?.turn ?? root.turn; const hidden = isHiddenTurn(roster, sessionId, turn); - const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); + const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); const calls = hidden ? [] : callTree(root); const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); @@ -17447,15 +17509,7 @@ function apply(ctx) { const style = installStyles("", "mentions"); let last = ""; const render = () => { - const next = roster.getSnapshot().bots.map((bot) => { - const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; - const chip = /\s/.test(bot.name) ? "" : `[class*="_userRow"] [data-ref-chip="file"][title="@${bot.name.replace(/["\\]/g, "\\$&")}" i]`; - const look = lookOf(bot); - const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; - const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; - const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; - return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; - }).join("\n"); + const next = mentionCss(roster.getSnapshot().bots); if (next !== last) style.textContent = last = next; }; render(); diff --git a/core/src/client/cells.js b/core/src/client/cells.js index d649b2ff3..bae37b105 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -5,6 +5,7 @@ import { inkText } from './inks.js' import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, typedBy, heldReplyOf, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts, isPluginTool } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' +import { botByName, rosterBot } from './sources.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -859,7 +860,9 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions const room = roster.roomsById[sessionId] return h('div', { className: 'bt-group-replies' }, replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } + const stored = roster.byId[reply.botId] + const resolved = reply.remote ? stored : (rosterBot(roster, reply.botId) ?? stored) + const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } if (reply.kind === 'user') { const who = reply.name && reply.name !== 'User' ? reply.name : reply.authorId // Another person's own line: their name and person mark beside a received bubble, @@ -1034,7 +1037,7 @@ export function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const root = node.data.root const turn = node.location?.turn?.turn ?? root.turn const hidden = isHiddenTurn(roster, sessionId, turn) - const findBot = nameOrId => roster.byId[nameOrId] ?? roster.bots.find(bot => bot.name.toLowerCase() === String(nameOrId ?? '').toLowerCase()) + const findBot = nameOrId => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId) const calls = hidden ? [] : callTree(root) const drawn = calls.filter(call => DRAWN.has(toolName(call))) // The newest call still running names the work, so a program reads as the tool it is in. diff --git a/core/src/client/cluster-order.js b/core/src/client/cluster-order.js new file mode 100644 index 000000000..60e443eb5 --- /dev/null +++ b/core/src/client/cluster-order.js @@ -0,0 +1,12 @@ +// Paint order for a group icon. RoomAvatar draws later seats on top, so a +// friend's look must not cover a local Bot (#963). With no remote seat the +// list stays as roomAvatarPeople returned it, including [member, you]. +export function clusterOrder(people) { + const list = Array.isArray(people) ? people : [] + const remote = bot => Boolean(bot?.remote) + if (!list.some(remote)) return list + const locals = list.filter(bot => !remote(bot)) + const remotes = list.filter(remote) + if (list.length <= 4) return [...remotes, ...locals] + return [...locals, ...remotes] +} diff --git a/core/src/client/index.js b/core/src/client/index.js index ae37191ff..53e4fdcdf 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -1,13 +1,13 @@ import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' -import { isHex, inkText, paintCss } from './inks.js' -import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js' +import { isHex } from './inks.js' +import { SHAPES, markupCache } from './characters.js' import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js' import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' -import { MENTION_ORIGIN, currentPart, landingPart, setPluginTools } from './text.js' +import { currentPart, landingPart, setPluginTools } from './text.js' +import { mentionCss } from './mention-style.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' import { installBrandChrome, installTabTitle } from './brand.js' -import { lookOf } from './mark.js' import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, ModelsSettingsStep, MODELS_STEP_ID, MODELS_STEP_ORDER, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' import { HeaderPill } from './header.js' import { installHeaderEffects } from './header-effects.js' @@ -290,17 +290,7 @@ export function apply(ctx) { const style = installStyles('', 'mentions') let last = '' const render = () => { - const next = roster.getSnapshot().bots.map((bot) => { - const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]` - const chip = /\s/.test(bot.name) ? '' : `[class*="_userRow"] [data-ref-chip="file"][title="@${bot.name.replace(/["\\]/g, '\\$&')}" i]` - const look = lookOf(bot) - const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` - const mark = look.image - ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` - : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` - const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' - return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` - }).join('\n') + const next = mentionCss(roster.getSnapshot().bots) if (next !== last) style.textContent = last = next } render() diff --git a/core/src/client/mark.js b/core/src/client/mark.js index dc323aad1..4af11e82b 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -2,6 +2,8 @@ import { createElement as h, useEffect, useRef, useState } from 'react' import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js' import { inkFill } from './inks.js' import { t } from './i18n.js' +import { clusterOrder } from './cluster-order.js' +import { rosterBot } from './sources.js' export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js' @@ -159,17 +161,18 @@ function clusterSeats(count, frame) { } export function roomAvatarPeople(room, roster) { - const members = (room?.members ?? []).map(id => roster?.byId?.[id]).filter(Boolean) + const members = (room?.members ?? []).map(id => rosterBot(roster, id)).filter(Boolean) const seen = new Set(members.map(bot => bot.id)) const remotes = [] for (const p of room?.participants ?? []) { if (p.leftSeq !== undefined) continue if (p.kind === 'human') continue if (!p.remote && p.id && seen.has(p.id)) continue - if (!p.remote && p.id && roster?.byId?.[p.id]) { - if (!seen.has(p.id)) { - members.push(roster.byId[p.id]) - seen.add(p.id) + const known = !p.remote && p.id ? rosterBot(roster, p.id) : undefined + if (known) { + if (!seen.has(known.id)) { + members.push(known) + seen.add(known.id) } continue } @@ -193,7 +196,7 @@ export function roomAvatarPeople(room, roster) { } export function RoomAvatar({ room, roster, size = 36 }) { - const people = roomAvatarPeople(room, roster) + const people = clusterOrder(roomAvatarPeople(room, roster)) const seat = (bot, seatSize, key) => (bot ? h(BotMark, { key, bot, size: seatSize }) : h('span', { key, className: 'bt-cluster-you', style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } })) diff --git a/core/src/client/mention-style.js b/core/src/client/mention-style.js new file mode 100644 index 000000000..ed4cf082e --- /dev/null +++ b/core/src/client/mention-style.js @@ -0,0 +1,37 @@ +import { lookOf, maskOf, DEFAULT_MASK } from './characters.js' +import { inkText, paintCss } from './inks.js' +import { MENTION_ORIGIN } from './text.js' + +// One @Name file chip per display name. A local Bot owns the chip when a +// friend has the same name; a later local still replaces an earlier local, +// and a later friend replaces an earlier friend only when no local has it (#963). +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null + return String(bot?.name ?? '').toLowerCase() +} + +export function mentionCss(bots) { + const list = Array.isArray(bots) ? bots : [] + const owner = new Map() + for (const bot of list) { + const key = chipKey(bot) + if (key === null) continue + const prev = owner.get(key) + if (prev && !prev.remote && bot?.remote) continue + owner.set(key, bot) + } + return list.map((bot) => { + const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]` + const key = chipKey(bot) + const chip = key !== null && owner.get(key) === bot + ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, '\\$&')}" i]` + : '' + const look = lookOf(bot) + const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` + const mark = look.image + ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` + : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` + const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` + }).join('\n') +} diff --git a/core/src/client/sources.js b/core/src/client/sources.js index fdf561abb..df64f4304 100644 --- a/core/src/client/sources.js +++ b/core/src/client/sources.js @@ -30,8 +30,17 @@ export function indexRoster(value) { setLocalBots(value.bots) setPluginTools(value.pluginTools) const byId = {} + // A remote chat must not replace a local Bot's id or session (#963). Nobody + // takes another Bot's own id; the same Bot may refresh its own keys. + const claim = (bot, id) => { + if (id === undefined || 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]) byId[id] = bot + for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) claim(bot, id) } const roomsById = {} for (const room of value.rooms) roomsById[room.id] = room @@ -45,6 +54,25 @@ export function indexRoster(value) { return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true } } +// A member id, session id, or part id. A local Bot wins when a friend's chat +// was indexed under the same key (#963). +export function rosterBot(roster, id) { + if (id === undefined || id === null || id === '') return undefined + 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] +} + +// The local Bot of this display name, else a friend's Bot with that name (#963). +export function botByName(bots, name) { + const wanted = String(name ?? '').toLowerCase() + if (wanted === '') return undefined + 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) +} + // Whether a main-view Session id belongs to the team's DSH Agent: the conversation // group then stays withdrawn while the shell keeps the sidebar. Before the roster // arrives, the ids cached from the last pull answer. diff --git a/core/src/client/text.js b/core/src/client/text.js index 5af5707bc..c080a2a09 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -10,9 +10,12 @@ export const MENTION_ORIGIN = 'https://bots.local/' // ("@新 Bot" in "@新 Bot 2"); it then stays plain text. export function linkMentions(text, bots, selfId) { if (!text || !text.includes('@')) return text + // Same-length names keep their order. Locals go first so "@Chief" names my + // Chief when a friend uses that title too (#963). + const ordered = [...(bots ?? [])].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))) let out = '' let cursor = 0 - for (const span of mentionSpans(text, bots)) { + for (const span of mentionSpans(text, ordered)) { if (!span.bot || span.bot.id === selfId) continue out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, '\\$&')}](${MENTION_ORIGIN}${span.bot.id})` cursor = span.end diff --git a/core/test/local-look.test.js b/core/test/local-look.test.js new file mode 100644 index 000000000..bdc10e11c --- /dev/null +++ b/core/test/local-look.test.js @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { lookOf } from '../src/client/characters.js' +import { clusterOrder } from '../src/client/cluster-order.js' +import { mentionCss } from '../src/client/mention-style.js' +import { botByName, indexRoster, rosterBot } from '../src/client/sources.js' +import { linkMentions, MENTION_ORIGIN } from '../src/client/text.js' + +const local = { + id: 'chief', + name: 'Chief', + color: 'deepseek', + avatar: { shape: 'whale' }, + sessionId: 'chief-now', + parts: ['chief'], +} +const friend = { + id: 'remote-1', + name: 'Chief', + color: '#ff5eb1', + avatar: { shape: 'moon' }, + remote: { handle: '@chief.flybot98b8ecba.home' }, + // A friend's chat indexed under the local Bot's own id and current session. + sessionId: 'chief-now', + parts: ['chief'], +} + +test('a friend chat does not take a local Bot\'s id or session in the roster index (#963)', () => { + const roster = indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] }) + assert.equal(roster.byId.chief, local) + assert.equal(roster.byId['chief-now'], local) + assert.equal(roster.byId['remote-1'], friend) + assert.equal(lookOf(roster.byId.chief).color, 'deepseek') + assert.equal(lookOf(roster.byId.chief).shape, 'whale') + assert.equal(lookOf(roster.byId['remote-1']).color, '#ff5eb1') + // The friend's chat is listed first: the local Bot still owns its keys. + const flipped = indexRoster({ bots: [friend, local], rooms: [] }) + assert.equal(flipped.byId.chief, local) + assert.equal(flipped.byId['chief-now'], local) +}) + +test('a member id resolves to the local Bot when a friend shares that key (#963)', () => { + const roster = indexRoster({ bots: [local, friend], rooms: [] }) + assert.equal(rosterBot(roster, 'chief').color, 'deepseek') + assert.equal(rosterBot(roster, 'chief-now').avatar.shape, 'whale') + assert.equal(rosterBot(roster, 'remote-1').id, 'remote-1') + assert.equal(botByName(roster.bots, 'Chief').id, 'chief') + assert.equal(botByName([friend, local], 'Chief').id, 'chief') + assert.equal(botByName([friend], 'Chief').id, 'remote-1') +}) + +test('an @Name chip keeps the local Bot\'s look when a friend has the same name (#963)', () => { + const css = mentionCss([local, friend]) + const chip = css.split('\n').filter(line => line.includes('title="@Chief"')) + assert.ok(chip.length > 0) + assert.ok(chip.every(line => line.includes('var(--bt-ink-deepseek)') || line.includes('whale') || !line.includes('#ff5eb1'))) + assert.equal(chip.some(line => line.includes('#ff5eb1')), false) + assert.equal(chip.some(line => line.includes('var(--bt-ink-deepseek)')), true) + // The friend's own mention anchor stays the friend's color. + assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}remote-1"\\]\\{color:#ff5eb1`)) + assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}chief"\\]\\{color:var\\(--bt-ink-deepseek\\)`)) + // A name only a friend has still wears that friend's look. + const only = mentionCss([{ id: 'h', name: 'Helper', color: '#ff5eb1', remote: { handle: '@helper.other.home' } }]) + assert.match(only, /title="@Helper"/) + assert.match(only, /#ff5eb1/) + // Friend listed first: the local Bot still owns the shared chip. + const flipped = mentionCss([friend, local]).split('\n').filter(line => line.includes('title="@Chief"')) + assert.equal(flipped.some(line => line.includes('#ff5eb1')), false) +}) + +test('@Chief in a bubble names the local Bot when a friend is listed first (#963)', () => { + const linked = linkMentions('@Chief hello', [friend, local], 'someone-else') + assert.equal(linked, `[Chief](${MENTION_ORIGIN}chief) hello`) +}) + +test('a friend seat does not cover the local Bot in a group icon (#963)', () => { + const you = null + assert.deepEqual(clusterOrder([local, you]).map(bot => bot?.id ?? 'you'), ['chief', 'you']) + assert.deepEqual(clusterOrder([local, friend]).map(bot => bot.id), ['remote-1', 'chief']) + const many = clusterOrder([local, { id: 'b', name: 'B' }, friend, { id: 'r2', name: 'C', remote: { handle: '@c' } }, { id: 'r3', name: 'D', remote: { handle: '@d' } }]) + assert.deepEqual(many.map(bot => bot.id), ['chief', 'b', 'remote-1', 'r2', 'r3']) + const mark = readFileSync(new URL('../src/client/mark.js', import.meta.url), 'utf8') + assert.match(mark, /clusterOrder\(roomAvatarPeople/) + const client = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(client, /mentionCss\(/) + const cells = readFileSync(new URL('../src/client/cells.js', import.meta.url), 'utf8') + assert.match(cells, /botByName\(roster\.bots/) + assert.match(cells, /rosterBot\(roster, reply\.botId\)/) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 699be6a7e..b3d329bb8 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -76,7 +76,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Room avatars in face-to-face chats -- `RoomAvatar` (`src/client/mark.js` `roomAvatarPeople`) seats current local `room.members` and live remote `participants` (handle-seeded via `lookBotForRemote`). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header. +- `RoomAvatar` (`src/client/mark.js` `roomAvatarPeople`) seats current local `room.members` and live remote `participants` (handle-seeded via `lookBotForRemote`). A face-to-face room with only remotes no longer falls back to the grey placeholder in the sidebar row or chat header. `roomAvatarPeople` still returns locals first, then remotes, and a one-member group is still `[member, you]`. `RoomAvatar` paints `clusterOrder` (`src/client/cluster-order.js`): with no remote the order is unchanged; with four or fewer seats the remotes are drawn first and the locals last, so a friend's look does not cover the local Bot; past four, locals stay in the visible seats (#963). ### Room remote bubbles unwrap the fence @@ -88,6 +88,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). - In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305). - A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). +- A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look. - A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). - The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1b279e8e3..19b56251b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1796,6 +1796,20 @@ diff --git a/src/client/schedules.js b/src/client/schedules.js diff --git a/src/client/text.js b/src/client/text.js --- a/src/client/text.js +++ b/src/client/text.js +@@ -10,9 +10,12 @@ export const MENTION_ORIGIN = 'https://bots.local/' + // ("@新 Bot" in "@新 Bot 2"); it then stays plain text. + export function linkMentions(text, bots, selfId) { + if (!text || !text.includes('@')) return text ++ // Same-length names keep their order. Locals go first so "@Chief" names my ++ // Chief when a friend uses that title too (#963). ++ const ordered = [...(bots ?? [])].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))) + let out = '' + let cursor = 0 +- for (const span of mentionSpans(text, bots)) { ++ for (const span of mentionSpans(text, ordered)) { + if (!span.bot || span.bot.id === selfId) continue + out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, '\\$&')}](${MENTION_ORIGIN}${span.bot.id})` + cursor = span.end @@ -25,6 +25,25 @@ export function mentionTarget(event) { return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null } diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index cff00b48c..d816b75c7 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -437,7 +437,7 @@ diff --git a/src/client/bot-settings.js b/src/client/bot-settings.js diff --git a/src/client/cells.js b/src/client/cells.js --- a/src/client/cells.js +++ b/src/client/cells.js -@@ -2,12 +2,19 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR +@@ -2,12 +2,20 @@ import { createElement as h, Fragment, useEffect, useLayoutEffect, useMemo, useR import { createPortal } from 'react-dom' import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' @@ -446,6 +446,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, toolState, BotMark, RoomAvatar } from './mark.js' +import { colorOf, lookBotForRemote, toolState, BotMark, PersonMark, RoomAvatar } from './mark.js' ++import { botByName, rosterBot } from './sources.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -883,7 +884,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js timer = setTimeout(tick, document.hidden ? 5000 : 1000) } void tick() -@@ -562,20 +562,29 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi +@@ -562,20 +562,31 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi if (done) { try { replies = JSON.parse(contentText(content)).replies ?? [] } catch { replies = [] } } @@ -897,7 +898,10 @@ diff --git a/src/client/cells.js b/src/client/cells.js const room = roster.roomsById[sessionId] return h('div', { className: 'bt-group-replies' }, replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } ++ const stored = roster.byId[reply.botId] ++ const resolved = reply.remote ? stored : (rosterBot(roster, reply.botId) ?? stored) ++ const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? 'gray' } if (reply.kind === 'user') { - return h('div', { key: `user-${index}`, className: 'bt-urow' }, h('div', { className: 'bt-ububble' }, - h(MarkdownText, { text: reply.text, labels: mdLabels(), variant: 'compact' }))) @@ -971,7 +975,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js case 'create_group': case 'update_group': case 'delete_group': -@@ -719,7 +719,7 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot +@@ -719,12 +719,12 @@ function renderCall(call, { sessionId, roster, actions, useUi, useWork, findBot } } @@ -980,6 +984,12 @@ diff --git a/src/client/cells.js b/src/client/cells.js const roster = useRoster(value => value) const root = node.data.root const turn = node.location?.turn?.turn ?? root.turn + const hidden = isHiddenTurn(roster, sessionId, turn) +- const findBot = nameOrId => roster.byId[nameOrId] ?? roster.bots.find(bot => bot.name.toLowerCase() === String(nameOrId ?? '').toLowerCase()) ++ const findBot = nameOrId => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId) + const calls = hidden ? [] : callTree(root) + const drawn = calls.filter(call => DRAWN.has(toolName(call))) + // The newest call still running names the work, so a program reads as the tool it is in. @@ -734,6 +734,6 @@ export function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions } useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note) useShownCards(actions, sessionId, drawn.filter(call => CARDS.has(toolName(call)) && call.callId).map(call => call.callId)) @@ -1157,16 +1167,19 @@ diff --git a/src/client/index.js b/src/client/index.js @@ -1,44 +1,64 @@ +import { FLYBOT_CSS, FlyBotApprovals, FlyBotSessionDock, createClientApi as createFlyBotClient } from './flybot-slots.js' import { call } from './api.js' - import { isHex, inkText, paintCss } from './inks.js' - import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js' +-import { isHex, inkText, paintCss } from './inks.js' +-import { DEFAULT_MASK, SHAPES, markupCache, maskOf } from './characters.js' ++import { isHex } from './inks.js' ++import { SHAPES, markupCache } from './characters.js' import { countCards, createSource, EMPTY_ROSTER, indexRoster, isAgentSession } from './sources.js' import { themeCss, shellTokens, registry, registerShape, registerTheme, allThemes } from './themes.js' -import { MENTION_ORIGIN, currentPart } from './text.js' -+import { MENTION_ORIGIN, currentPart, landingPart, setPluginTools } from './text.js' ++import { currentPart, landingPart, setPluginTools } from './text.js' ++import { mentionCss } from './mention-style.js' import { SHELL_CSS, CONVERSATION_CSS, installStyles, installCompiledStyles } from './styles.js' -+import { installBrandChrome, installTabTitle } from './brand.js' - import { lookOf } from './mark.js' +-import { lookOf } from './mark.js' -import { TeamSidebar, ConnectPlugins, YouAvatar, AccountLauncher, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' ++import { installBrandChrome, installTabTitle } from './brand.js' +import { TeamSidebar, ConnectApps, YouAvatar, AccountLauncher, ModelsSettingsStep, MODELS_STEP_ID, MODELS_STEP_ORDER, FooterAccount, BrandMark, BrandName, BotReturn, SIDEBAR_CSS, RETURN_CSS, holdShellShortcuts } from './sidebar.js' import { HeaderPill } from './header.js' -import { ComposerPlus, ComposerVoice, menuIcons } from './composer.js' @@ -1282,6 +1295,25 @@ diff --git a/src/client/index.js b/src/client/index.js try { const ids = (value.agents ?? []).flatMap(agent => agent.sessions ?? []) localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)) +@@ -252,17 +252,7 @@ export function apply(ctx) { + const style = installStyles('', 'mentions') + let last = '' + const render = () => { +- const next = roster.getSnapshot().bots.map((bot) => { +- const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]` +- const chip = /\s/.test(bot.name) ? '' : `[class*="_userRow"] [data-ref-chip="file"][title="@${bot.name.replace(/["\\]/g, '\\$&')}" i]` +- const look = lookOf(bot) +- const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` +- const mark = look.image +- ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` +- : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` +- const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' +- return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` +- }).join('\n') ++ const next = mentionCss(roster.getSnapshot().bots) + if (next !== last) style.textContent = last = next + } + render() @@ -325,7 +325,7 @@ export function apply(ctx) { ctx.inject(['shortcuts'], scope => { scope.effect(() => holdShellShortcuts(scope.shortcuts), 'ds-bot: settings shortcut') }) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index f7d1d7973..c7220a17a 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -101,12 +101,14 @@ diff --git a/src/client/characters.js b/src/client/characters.js diff --git a/src/client/mark.js b/src/client/mark.js --- a/src/client/mark.js +++ b/src/client/mark.js -@@ -1,8 +1,9 @@ +@@ -1,8 +1,11 @@ import { createElement as h, useEffect, useRef, useState } from 'react' -import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' +import { fnv1a, lookOf, lookAvoidingLocals, lookBotForRemote, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup, defaultColor } from './characters.js' +import { inkFill } from './inks.js' import { t } from './i18n.js' ++import { clusterOrder } from './cluster-order.js' ++import { rosterBot } from './sources.js' -export { colorOf, lookOf } from './characters.js' +export { colorOf, lookOf, lookKey, lookKeys, lookAvoidingLocals, remoteSeed, lookBotForRemote } from './characters.js' @@ -172,22 +174,23 @@ diff --git a/src/client/mark.js b/src/client/mark.js export function AdminStar() { return h('span', { className: 'bt-admin-star', role: 'img', 'aria-label': t('Admin') }, h(StarIcon, { size: 7 })) } -@@ -130,10 +130,42 @@ function clusterSeats(count, frame) { +@@ -130,10 +130,43 @@ function clusterSeats(count, frame) { return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]] } +export function roomAvatarPeople(room, roster) { -+ const members = (room?.members ?? []).map(id => roster?.byId?.[id]).filter(Boolean) ++ const members = (room?.members ?? []).map(id => rosterBot(roster, id)).filter(Boolean) + const seen = new Set(members.map(bot => bot.id)) + const remotes = [] + for (const p of room?.participants ?? []) { + if (p.leftSeq !== undefined) continue + if (p.kind === 'human') continue + if (!p.remote && p.id && seen.has(p.id)) continue -+ if (!p.remote && p.id && roster?.byId?.[p.id]) { -+ if (!seen.has(p.id)) { -+ members.push(roster.byId[p.id]) -+ seen.add(p.id) ++ const known = !p.remote && p.id ? rosterBot(roster, p.id) : undefined ++ if (known) { ++ if (!seen.has(known.id)) { ++ members.push(known) ++ seen.add(known.id) + } + continue + } @@ -214,7 +217,7 @@ diff --git a/src/client/mark.js b/src/client/mark.js - const members = room.members.map(id => roster.byId[id]).filter(Boolean) - // A group with one Bot left shows the user beside it. - const people = members.length === 1 ? [members[0], null] : members -+ const people = roomAvatarPeople(room, roster) ++ const people = clusterOrder(roomAvatarPeople(room, roster)) const seat = (bot, seatSize, key) => (bot ? h(BotMark, { key, bot, size: seatSize }) : h('span', { key, className: 'bt-cluster-you', style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } })) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 63b8b3de3..42edbd238 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1130,7 +1130,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,162 +1242,621 @@ function maskOf(shape) { +@@ -1242,162 +1242,16 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1153,43 +1153,7 @@ diff --git a/client.js b/client.js - for (const listener of [...listeners2]) listener(); - } - }; -+// 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 EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, 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) { @@ -1204,84 +1168,23 @@ diff --git a/client.js b/client.js - for (const agent of value.agents ?? []) { - agentsById[agent.id] = agent; - for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; -+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)); - } +- } - return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+ 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 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 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" }) -+ ); - } +-} -function countCards(state, sessionId, callIds, delta) { - const own = { ...state[sessionId] }; - for (const callId of callIds) { - const count = (own[callId] ?? 0) + delta; - if (count > 0) own[callId] = count; - else delete own[callId]; -+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 { ...state, [sessionId]: own }; -+ 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" }) -+ ) -+ ); - } +-} - -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -1328,9 +1231,7 @@ diff --git a/client.js b/client.js - "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), - "--bt-tag-ink": rgba(mode.text, 0.58) - }; -+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 })); - } +-} -var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); -var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} -html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} @@ -1368,113 +1269,713 @@ diff --git a/client.js b/client.js - registry.set((value) => value + 1); - }; - refresh(); -+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) => roster?.byId?.[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; -+ if (!p.remote && p.id && roster?.byId?.[p.id]) { -+ if (!seen.has(p.id)) { -+ members.push(roster.byId[p.id]); -+ seen.add(p.id); -+ } +- return () => { +- if (previous) SHAPES[id] = previous; +- else delete SHAPES[id]; +- refresh(); +- }; +-} +-function registerTheme(id, theme) { +- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); +- for (const mode of ["light", "dark"]) { +- for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); +- } +- const previous = customThemes[id]; +- customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; +- registry.set((value) => value + 1); +- return () => { +- if (previous) customThemes[id] = previous; +- else delete customThemes[id]; +- registry.set((value) => value + 1); +- }; ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } +-var allThemes = () => ({ ...THEMES, ...customThemes }); + + // src/shared/mentions.js + var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; +@@ -1445,24 +1445,59 @@ function mentionSpans(text, bots, { references = [] } = {}) { + } + return spans; + } +- +-// src/client/text.js +-var MENTION_ORIGIN = "https://bots.local/"; +-function linkMentions(text, bots, selfId) { +- if (!text || !text.includes("@")) return text; +- let out = ""; +- let cursor = 0; +- for (const span of mentionSpans(text, bots)) { +- if (!span.bot || span.bot.id === selfId) continue; +- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; +- cursor = span.end; +- } +- return cursor === 0 ? text : out + text.slice(cursor); ++function unmatchedMentions(text, bots, { references = [] } = {}) { ++ const value = String(text ?? ""); ++ const skip = [ ++ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ++ ...referenceSpans(value, references) ++ ]; ++ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); ++ const missing = []; ++ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { ++ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; ++ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); ++ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); ++ if (bot || all) { ++ at += 1 + (bot ? bot.name.length : all.length) - 1; + 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)); ++ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; ++ if (word) missing.push(`@${word}`); + } -+ if (members.length === 1 && remotes.length === 0) return [members[0], null]; -+ return [...members, ...remotes]; ++ return missing; +} -+function RoomAvatar({ room: room2, roster, size = 36 }) { -+ const people2 = 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 -+ ); ++ ++// src/client/text.js ++var MENTION_ORIGIN = "https://bots.local/"; ++function linkMentions(text, bots, selfId) { ++ if (!text || !text.includes("@")) return text; ++ const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); ++ let out = ""; ++ let cursor = 0; ++ for (const span of mentionSpans(text, ordered)) { ++ if (!span.bot || span.bot.id === selfId) continue; ++ out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; ++ cursor = span.end; + } -+ 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}`); ++ return cursor === 0 ? text : out + text.slice(cursor); + } + function mentionTarget(event) { + const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; + return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; + } ++function nameMentions(text, roster) { ++ const value = String(text ?? ""); ++ if (!value.includes("@") || roster === null || typeof roster !== "object") return value; ++ const names = /* @__PURE__ */ new Map(); ++ for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { ++ const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; ++ if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); ++ } ++ const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); ++ if (handles.length === 0) return value; ++ const source = handles.map((handle) => handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); ++ return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, "gi"), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`); ++} + function clock(ms) { + return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); + } +@@ -1483,6 +1483,15 @@ function relativeShort(ms) { + var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); + var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; + var currentPart = (bot) => bot.sessionId ?? bot.id; ++function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { ++ const bot = last ? roster.byId[last.botId] : void 0; ++ if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); ++ if (bot && stay) return last.sessionId; ++ const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; ++ if (held) return last.sessionId; ++ const main = roster.byId[mainId]; ++ return main ? currentPart(main) : mainId; ++} + function reminderPrompts(text) { + const one = /^reminder_prompt_json: (.*)$/m.exec(text); + const batch = /^reminders_json: (.*)$/m.exec(text); +@@ -1494,6 +1494,13 @@ function reminderPrompts(text) { + } + } + var stripHeader = (text) => text.replace(HEADER, ""); ++var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; ++function stripTrace(text) { ++ if (typeof text !== "string") return text; ++ const line2 = text.match(TRACE)?.[0]; ++ if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; ++ return text.slice(line2.length).replace(/^\s+/, ""); ++} + function latestMemoryChange(news, ids) { + const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; + return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); +@@ -1506,25 +1506,35 @@ function summaryBlocks(text) { + paragraph = []; + }; + for (const raw of String(text ?? "").split("\n")) { +- const line = raw.trim().replace(/\*\*|__/g, ""); +- const heading = /^#{1,6}\s+(.+)$/.exec(line); ++ const line2 = raw.trim().replace(/\*\*|__/g, ""); ++ const heading = /^#{1,6}\s+(.+)$/.exec(line2); + if (heading) { + flush(); + blocks.push({ kind: "h", text: heading[1].trim() }); +- } else if (line === "") { ++ } else if (line2 === "") { + flush(); + } else { +- paragraph.push(line.replace(/^[-*•]\s+/, "")); ++ paragraph.push(line2.replace(/^[-*•]\s+/, "")); + } + } + flush(); + return blocks; + } ++function typedBy(roster, sessionId, turn) { ++ if (sessionId === void 0 || turn === void 0) return void 0; ++ const name = roster.remoteTypedTurns?.[sessionId]?.[turn]; ++ return typeof name === "string" && name ? name : void 0; ++} + function isHiddenTurn(roster, sessionId, turn) { + if (sessionId === void 0 || turn === void 0) return false; + return (roster.exchangeTurns[sessionId] ?? []).includes(turn); + } + var toolName = (root) => root.name || root.call?.name; ++var pluginTools = /* @__PURE__ */ new Set(); ++function setPluginTools(names) { ++ pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); ++} ++var isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); + 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 = []) { + } + return { count, peerIds }; + } +-function previewOf(list, id, hiddenTurns = []) { ++function heldReplyOf(bot, sessionId, turn) { ++ const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; ++ let found; ++ for (const reply of list) if (reply.sessionId === sessionId && String(reply.turn) === String(turn)) found = reply; ++ 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 = []) { + 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 = 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 stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); + } + return ""; + } +@@ -1624,209 +1624,1052 @@ 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 }); + } +- +-// src/client/motion.js +-var import_react = require("react"); +-var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; +-function springEasing(stiffness, damping) { +- const w0 = Math.sqrt(stiffness); +- const zeta = damping / (2 * w0); +- const at = (t2) => { +- if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); +- const wd = w0 * Math.sqrt(1 - zeta * zeta); +- return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); +- }; +- let end = 0.05; +- while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; +- const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); +- return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; ++function faceRowTitle(title) { ++ const text = String(title ?? ""); ++ const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); ++ if (named) return `${named[2]} · ${named[1]}`; ++ return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; + } +-var SPRING_SHAPE = springEasing(400, 31.2); +-var SPRING_TAP = springEasing(900, 48); +-var SPRING_LIST = springEasing(484, 35.2); +-var SPRING_STRETCH = springEasing(320, 20); +-var SPRING_POP = springEasing(500, 22); +-var SPRING_LEAD = springEasing(784, 48.2); +-var SPRING_TRAIL = springEasing(289, 29.2); +-var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; +-var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); +-function liveSprings(apply2) { +- const values = /* @__PURE__ */ new Map(); +- let frame = 0; +- let last = 0; +- const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); +- const step = (now) => { +- const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); +- last = now; +- const steps = Math.ceil(dt * 240); +- const h34 = dt / steps; +- let moving = false; +- for (const value of values.values()) { +- for (let index = 0; index < steps; index += 1) { +- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; +- value.x += value.v * h34; +- } +- if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { +- value.x = value.to; +- value.v = 0; +- } else moving = true; +- } +- apply2(read()); +- frame = moving ? requestAnimationFrame(step) : 0; +- }; ++function modelOf(bot, roster) { ++ const ref = bot.model ?? bot.appliedModel; ++ if (!ref) return void 0; ++ const served = (roster.models ?? []).find((model) => model.ref === ref); ++ const [provider, ...rest] = ref.split("/"); ++ return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; ++} ++function botRowTitle(name, model, wide) { ++ if (model) return `${name} · ${model}`; ++ return wide ? void 0 : name; ++} ++function awayGroups(groups, roomsById) { ++ return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); ++} ++ ++// src/client/sources.js ++function createSource(initial) { ++ let value = initial; ++ const listeners2 = /* @__PURE__ */ new Set(); + return { +- target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, +- set(targets, curves, jump) { +- for (const [key, to] of Object.entries(targets)) { +- const value = values.get(key); +- const { stiffness: k, damping: c } = curves[key]; +- if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); +- else Object.assign(value, { to, k, c }); +- } +- if (jump) { +- cancelAnimationFrame(frame); +- frame = 0; +- apply2(read()); +- return; +- } +- if (!frame) { +- last = performance.now(); +- frame = requestAnimationFrame(step); +- } ++ getSnapshot: () => value, ++ subscribe: (listener) => { ++ listeners2.add(listener); ++ return () => { ++ listeners2.delete(listener); ++ }; + }, +- 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); +- const box = given ?? ownBox; +- const thumb = (0, import_react.useRef)(null); +- const own = (0, import_react.useRef)(null); +- if (own.current === null) { +- own.current = { +- key: void 0, +- node: null, +- observer: null, +- springs: liveSprings(({ start, end }) => { +- const knob = thumb.current; +- if (!knob) return; +- knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; +- knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; +- }) +- }; +- } +- const place = (jump) => { +- const container = box.current; +- const knob = thumb.current; +- if (!container || !knob) return; +- const chosen = container.querySelector(selector); +- if (!chosen) { +- knob.dataset.hidden = ""; +- return; +- } +- const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; +- const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); +- const { springs } = own.current; +- const before = springs.target(); +- const hidden = knob.dataset.hidden !== void 0; +- if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; +- delete knob.dataset.hidden; +- 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; +- const node = box.current; +- if (node === state.node && key === state.key) return; +- const fresh = node !== state.node; +- state.key = key; +- if (fresh) { +- state.observer?.disconnect(); +- state.node = node; +- state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; +- state.observer?.observe(node); +- } +- place(fresh); +- }); +- (0, import_react.useEffect)(() => () => { +- own.current.observer?.disconnect(); +- own.current.springs.stop(); +- }, []); +- return { box, thumb }; ++ for (const bot of value.bots) { ++ for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) claim(bot, id); ++ } ++ const roomsById = {}; ++ for (const room2 of value.rooms) roomsById[room2.id] = room2; ++ const agentsById = {}; ++ const agentOf = {}; ++ for (const agent of value.agents ?? []) { ++ agentsById[agent.id] = agent; ++ for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; ++ } ++ 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)(() => { +- const container = list.current; +- if (!container) return; +- const next = /* @__PURE__ */ new Map(); +- const moving = tops.current !== null && !reducedMotion(); +- for (const node of container.querySelectorAll(selector)) { +- const name = id(node); +- const top = node.offsetTop; +- next.set(name, top); +- if (!moving) continue; +- const before = tops.current.get(name); +- if (before === void 0) { +- node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); +- } else if (Math.abs(before - top) > 0.5) { +- node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); +- } +- } +- 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 useMorph(box, key) { +- const last = (0, import_react.useRef)({ key, height: null }); +- (0, import_react.useLayoutEffect)(() => { +- const node = box.current; +- const state = last.current; +- if (!node) { +- last.current = { key, height: null }; +- return; +- } +- const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); +- if (key === state.key) { +- if (running.length === 0) state.height = node.offsetHeight; +- return; +- } +- const from = running.length ? node.offsetHeight : state.height; +- for (const animation2 of running) animation2.cancel(); +- const to = node.offsetHeight; +- last.current = { key, height: to }; +- if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; +- node.dataset.morph = ""; +- const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); +- animation.onfinish = () => { +- delete node.dataset.morph; +- }; +- animation.oncancel = () => { +- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; +- }; +- }); ++function isAgentSession(roster, cache, sessionId) { ++ if (sessionId === void 0 || sessionId === null || sessionId === "") return false; ++ if (roster.ready) return roster.agentOf[sessionId] !== void 0; ++ return cache.has(sessionId); + } +-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 countCards(state, sessionId, callIds, delta) { ++ const own = { ...state[sessionId] }; ++ for (const callId of callIds) { ++ const count = (own[callId] ?? 0) + delta; ++ if (count > 0) own[callId] = count; ++ 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 (!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 (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)); @@ -1584,17 +2085,10 @@ diff --git a/client.js b/client.js +} +function onClaims(fn) { + claimListeners.add(fn); - return () => { -- if (previous) SHAPES[id] = previous; -- else delete SHAPES[id]; -- refresh(); ++ return () => { + claimListeners.delete(fn); - }; - } --function registerTheme(id, theme) { -- if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); -- for (const mode of ["light", "dark"]) { -- for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); ++ }; ++} + +// src/shared/approval-waiting.js +var WAITING_STATUSES = Object.freeze(["pending", "pending_approval"]); @@ -1630,14 +2124,7 @@ diff --git a/client.js b/client.js + constructor(props) { + super(props); + this.state = { failed: false }; - } -- const previous = customThemes[id]; -- customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; -- registry.set((value) => value + 1); -- return () => { -- if (previous) customThemes[id] = previous; -- else delete customThemes[id]; -- registry.set((value) => value + 1); ++ } + static getDerivedStateFromError() { + return { failed: true }; + } @@ -1796,7 +2283,7 @@ diff --git a/client.js b/client.js + setBusy(""); + await refresh(); + } - }; ++ }; + const byChat = /* @__PURE__ */ new Map(); + for (const approval of pointers) { + const key = chatOf(approval, roster) ?? approval.id; @@ -1853,8 +2340,7 @@ diff --git a/client.js b/client.js + }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) + ) : null + ); - } --var allThemes = () => ({ ...THEMES, ...customThemes }); ++} +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} @@ -1879,267 +2365,19 @@ diff --git a/client.js b/client.js +.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/shared/mentions.js - var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1445,6 +1445,27 @@ function mentionSpans(text, bots, { references = [] } = {}) { - } - return spans; - } -+function unmatchedMentions(text, bots, { references = [] } = {}) { -+ const value = String(text ?? ""); -+ const skip = [ -+ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), -+ ...referenceSpans(value, references) -+ ]; -+ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); -+ const missing = []; -+ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { -+ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; -+ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); -+ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); -+ if (bot || all) { -+ at += 1 + (bot ? bot.name.length : all.length) - 1; -+ continue; -+ } -+ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; -+ if (word) missing.push(`@${word}`); -+ } -+ return missing; -+} - - // src/client/text.js - var MENTION_ORIGIN = "https://bots.local/"; -@@ -1463,6 +1463,19 @@ function mentionTarget(event) { - const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; - return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; - } -+function nameMentions(text, roster) { -+ const value = String(text ?? ""); -+ if (!value.includes("@") || roster === null || typeof roster !== "object") return value; -+ const names = /* @__PURE__ */ new Map(); -+ for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) { -+ const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle; -+ if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name); -+ } -+ const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length); -+ if (handles.length === 0) return value; -+ const source = handles.map((handle) => handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"); -+ return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, "gi"), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`); -+} - function clock(ms) { - return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); - } -@@ -1483,6 +1483,15 @@ function relativeShort(ms) { - var contentText = (content) => (content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n"); - var HEADER = /^\[(Message from|Reply from|Group chat|Group post from|Bot team setup|Handoff · part|Secret card)[^\]]*\]\n?/; - var currentPart = (bot) => bot.sessionId ?? bot.id; -+function landingPart(roster, last, mainId, { stay = false, view = "", sessions } = {}) { -+ const bot = last ? roster.byId[last.botId] : void 0; -+ if (bot && currentPart(bot) !== last.sessionId) return currentPart(bot); -+ if (bot && stay) return last.sessionId; -+ const held = last !== null && last !== void 0 && (stay || view === last.sessionId) && sessions?.byId?.[last.sessionId] !== void 0; -+ if (held) return last.sessionId; -+ const main = roster.byId[mainId]; -+ return main ? currentPart(main) : mainId; -+} - function reminderPrompts(text) { - const one = /^reminder_prompt_json: (.*)$/m.exec(text); - const batch = /^reminders_json: (.*)$/m.exec(text); -@@ -1494,6 +1494,13 @@ function reminderPrompts(text) { - } - } - var stripHeader = (text) => text.replace(HEADER, ""); -+var TRACE = /^[ \t]*[^\n→]{1,60}(?:[ \t]*→[ \t]*[^\n→]{1,60})+[ \t]*\n+/; -+function stripTrace(text) { -+ if (typeof text !== "string") return text; -+ const line2 = text.match(TRACE)?.[0]; -+ if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; -+ return text.slice(line2.length).replace(/^\s+/, ""); -+} - function latestMemoryChange(news, ids) { - const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; - return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); -@@ -1506,25 +1506,35 @@ function summaryBlocks(text) { - paragraph = []; - }; - for (const raw of String(text ?? "").split("\n")) { -- const line = raw.trim().replace(/\*\*|__/g, ""); -- const heading = /^#{1,6}\s+(.+)$/.exec(line); -+ const line2 = raw.trim().replace(/\*\*|__/g, ""); -+ const heading = /^#{1,6}\s+(.+)$/.exec(line2); - if (heading) { - flush(); - blocks.push({ kind: "h", text: heading[1].trim() }); -- } else if (line === "") { -+ } else if (line2 === "") { - flush(); - } else { -- paragraph.push(line.replace(/^[-*•]\s+/, "")); -+ paragraph.push(line2.replace(/^[-*•]\s+/, "")); - } - } - flush(); - return blocks; - } -+function typedBy(roster, sessionId, turn) { -+ if (sessionId === void 0 || turn === void 0) return void 0; -+ const name = roster.remoteTypedTurns?.[sessionId]?.[turn]; -+ return typeof name === "string" && name ? name : void 0; -+} - function isHiddenTurn(roster, sessionId, turn) { - if (sessionId === void 0 || turn === void 0) return false; - return (roster.exchangeTurns[sessionId] ?? []).includes(turn); - } - var toolName = (root) => root.name || root.call?.name; -+var pluginTools = /* @__PURE__ */ new Set(); -+function setPluginTools(names) { -+ pluginTools = new Set((Array.isArray(names) ? names : []).filter((name) => typeof name === "string")); -+} -+var isPluginTool = (name) => typeof name === "string" && pluginTools.has(name); - 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 = []) { - } - return { count, peerIds }; - } --function previewOf(list, id, hiddenTurns = []) { -+function heldReplyOf(bot, sessionId, turn) { -+ const list = Array.isArray(bot?.heldReplies) ? bot.heldReplies : []; -+ let found; -+ for (const reply of list) if (reply.sessionId === sessionId && String(reply.turn) === String(turn)) found = reply; -+ 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 = []) { - 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 = 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 stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""); - } - return ""; - } -@@ -1624,33 +1624,212 @@ 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 }); - } -+function faceRowTitle(title) { -+ const text = String(title ?? ""); -+ const named = text.match(/^Face-to-face · (.+) · (\d{6})$/); -+ if (named) return `${named[2]} · ${named[1]}`; -+ return text.match(/^Face-to-face · (\d{6})$/)?.[1] ?? text; -+} -+function modelOf(bot, roster) { -+ const ref = bot.model ?? bot.appliedModel; -+ if (!ref) return void 0; -+ const served = (roster.models ?? []).find((model) => model.ref === ref); -+ const [provider, ...rest] = ref.split("/"); -+ return { ref, name: served?.name ?? rest.join("/"), provider: served?.providerName ?? provider, served: served !== void 0 }; -+} -+function botRowTitle(name, model, wide) { -+ if (model) return `${name} · ${model}`; -+ return wide ? void 0 : name; -+} -+function awayGroups(groups, roomsById) { -+ return (groups ?? []).filter((entry) => roomsById?.[entry.id]?.origin !== "face-to-face"); -+} - --// src/client/motion.js --var import_react = require("react"); --var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; --function springEasing(stiffness, damping) { -- const w0 = Math.sqrt(stiffness); -- const zeta = damping / (2 * w0); -- const at = (t2) => { -- if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -- const wd = w0 * Math.sqrt(1 - zeta * zeta); -- return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -+// src/client/sources.js -+function createSource(initial) { -+ let value = initial; -+ const listeners2 = /* @__PURE__ */ new Set(); -+ return { -+ getSnapshot: () => value, -+ subscribe: (listener) => { -+ listeners2.add(listener); -+ return () => { -+ listeners2.delete(listener); -+ }; -+ }, -+ set(next) { -+ const resolved = typeof next === "function" ? next(value) : next; -+ if (resolved === value) return; -+ value = resolved; -+ for (const listener of [...listeners2]) listener(); -+ } - }; -- let end = 0.05; -- while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -- const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -- return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; - } --var SPRING_SHAPE = springEasing(400, 31.2); --var SPRING_TAP = springEasing(900, 48); --var SPRING_LIST = springEasing(484, 35.2); --var SPRING_STRETCH = springEasing(320, 20); --var SPRING_POP = springEasing(500, 22); --var SPRING_LEAD = springEasing(784, 48.2); --var SPRING_TRAIL = springEasing(289, 29.2); --var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; --var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); --function liveSprings(apply2) { -+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 = {}; -+ for (const bot of value.bots) { -+ for (const id of [...bot.parts ?? [], bot.sessionId ?? bot.id, bot.id]) byId[id] = bot; -+ } -+ const roomsById = {}; -+ for (const room2 of value.rooms) roomsById[room2.id] = room2; -+ const agentsById = {}; -+ const agentOf = {}; -+ for (const agent of value.agents ?? []) { -+ agentsById[agent.id] = agent; -+ for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; -+ } -+ return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; -+} -+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 countCards(state, sessionId, callIds, delta) { -+ const own = { ...state[sessionId] }; -+ for (const callId of callIds) { -+ const count = (own[callId] ?? 0) + delta; -+ if (count > 0) own[callId] = count; -+ else delete own[callId]; -+ } -+ return { ...state, [sessionId]: own }; -+} ++.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/themes.js +var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; @@ -2246,6 +2484,33 @@ diff --git a/client.js b/client.js +} +var allThemes = () => ({ ...THEMES, ...customThemes }); + ++// src/client/mention-style.js ++function chipKey(bot) { ++ if (/\s/.test(bot?.name)) return null; ++ return String(bot?.name ?? "").toLowerCase(); ++} ++function mentionCss(bots) { ++ const list = Array.isArray(bots) ? bots : []; ++ const owner = /* @__PURE__ */ new Map(); ++ for (const bot of list) { ++ const key = chipKey(bot); ++ if (key === null) continue; ++ const prev = owner.get(key); ++ if (prev && !prev.remote && bot?.remote) continue; ++ owner.set(key, bot); ++ } ++ return list.map((bot) => { ++ const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; ++ const key = chipKey(bot); ++ const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; ++ const look = lookOf(bot); ++ const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; ++ const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; ++ const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; ++ return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; ++ }).join("\n"); ++} ++ +// src/client/motion.js +var import_react3 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -2272,100 +2537,185 @@ diff --git a/client.js b/client.js +var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; +var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); +function liveSprings(apply2) { - const values = /* @__PURE__ */ new Map(); - let frame = 0; - let last = 0; -@@ -1659,12 +1659,12 @@ function liveSprings(apply2) { - const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); - last = now; - const steps = Math.ceil(dt * 240); -- const h34 = dt / steps; ++ const values = /* @__PURE__ */ new Map(); ++ let frame = 0; ++ let last = 0; ++ const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); ++ const step = (now) => { ++ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); ++ last = now; ++ const steps = Math.ceil(dt * 240); + const h42 = dt / steps; - let moving = false; - for (const value of values.values()) { - for (let index = 0; index < steps; index += 1) { -- value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; -- value.x += value.v * h34; ++ let moving = false; ++ for (const value of values.values()) { ++ for (let index = 0; index < steps; index += 1) { + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h42; + value.x += value.v * h42; - } - if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { - value.x = value.to; -@@ -1699,10 +1699,10 @@ function liveSprings(apply2) { - } - 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); ++ } ++ if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { ++ value.x = value.to; ++ value.v = 0; ++ } else moving = true; ++ } ++ apply2(read()); ++ frame = moving ? requestAnimationFrame(step) : 0; ++ }; ++ return { ++ target: () => values.size ? Object.fromEntries([...values].map(([key, value]) => [key, value.to])) : null, ++ set(targets, curves, jump) { ++ for (const [key, to] of Object.entries(targets)) { ++ const value = values.get(key); ++ const { stiffness: k, damping: c } = curves[key]; ++ if (!value || jump) values.set(key, { x: to, v: 0, to, k, c }); ++ else Object.assign(value, { to, k, c }); ++ } ++ if (jump) { ++ cancelAnimationFrame(frame); ++ frame = 0; ++ apply2(read()); ++ return; ++ } ++ if (!frame) { ++ last = performance.now(); ++ frame = requestAnimationFrame(step); ++ } ++ }, ++ stop: () => cancelAnimationFrame(frame) ++ }; ++} ++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_react3.useRef)(null); - const box = given ?? ownBox; -- const thumb = (0, import_react.useRef)(null); -- const own = (0, import_react.useRef)(null); ++ const box = given ?? ownBox; + const thumb = (0, import_react3.useRef)(null); + const own = (0, import_react3.useRef)(null); - if (own.current === null) { - own.current = { - key: void 0, -@@ -1736,7 +1736,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai - springs.set({ start, end }, forward ? { start: trail, end: lead } : { start: lead, end: trail }, jump || hidden || before === null || reducedMotion()); - container.dataset.glide = ""; - }; -- (0, import_react.useLayoutEffect)(() => { ++ if (own.current === null) { ++ own.current = { ++ key: void 0, ++ node: null, ++ observer: null, ++ springs: liveSprings(({ start, end }) => { ++ const knob = thumb.current; ++ if (!knob) return; ++ knob.style.transform = axis === "x" ? `translateX(${start}px)` : `translateY(${start}px)`; ++ knob.style[axis === "x" ? "width" : "height"] = `${Math.max(0, end - start)}px`; ++ }) ++ }; ++ } ++ const place = (jump) => { ++ const container = box.current; ++ const knob = thumb.current; ++ if (!container || !knob) return; ++ const chosen = container.querySelector(selector); ++ if (!chosen) { ++ knob.dataset.hidden = ""; ++ return; ++ } ++ const start = axis === "x" ? chosen.offsetLeft : chosen.offsetTop; ++ const end = start + (axis === "x" ? chosen.offsetWidth : chosen.offsetHeight); ++ const { springs } = own.current; ++ const before = springs.target(); ++ const hidden = knob.dataset.hidden !== void 0; ++ if (jump && !hidden && before?.start === start && before?.end === end && container.dataset.glide !== void 0) return; ++ delete knob.dataset.hidden; ++ 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 = ""; ++ }; + (0, import_react3.useLayoutEffect)(() => { - const state = own.current; - const node = box.current; - if (node === state.node && key === state.key) return; -@@ -1750,7 +1750,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai - } - place(fresh); - }); -- (0, import_react.useEffect)(() => () => { ++ const state = own.current; ++ const node = box.current; ++ if (node === state.node && key === state.key) return; ++ const fresh = node !== state.node; ++ state.key = key; ++ if (fresh) { ++ state.observer?.disconnect(); ++ state.node = node; ++ state.observer = node && typeof ResizeObserver === "function" ? new ResizeObserver(() => place(true)) : null; ++ state.observer?.observe(node); ++ } ++ place(fresh); ++ }); + (0, import_react3.useEffect)(() => () => { - own.current.observer?.disconnect(); - own.current.springs.stop(); - }, []); -@@ -1758,16 +1758,16 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai - } - function Segmented({ label, value, className, children }) { - const glide = useGlide(value); -- return (0, import_react.createElement)( ++ own.current.observer?.disconnect(); ++ own.current.springs.stop(); ++ }, []); ++ return { box, thumb }; ++} ++function Segmented({ label, value, className, children }) { ++ const glide = useGlide(value); + return (0, import_react3.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 }), ++ "div", ++ { ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label }, + (0, import_react3.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - children - ); - } - function useFlip(list, key, selector, id = (node) => node.dataset.flip) { -- const tops = (0, import_react.useRef)(null); -- (0, import_react.useLayoutEffect)(() => { ++ children ++ ); ++} ++function useFlip(list, key, selector, id = (node) => node.dataset.flip) { + const tops = (0, import_react3.useRef)(null); + (0, import_react3.useLayoutEffect)(() => { - const container = list.current; - if (!container) return; - const next = /* @__PURE__ */ new Map(); -@@ -1788,8 +1788,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) { - }, [key]); - } - function useMorph(box, key) { -- const last = (0, import_react.useRef)({ key, height: null }); -- (0, import_react.useLayoutEffect)(() => { ++ const container = list.current; ++ if (!container) return; ++ const next = /* @__PURE__ */ new Map(); ++ const moving = tops.current !== null && !reducedMotion(); ++ for (const node of container.querySelectorAll(selector)) { ++ const name = id(node); ++ const top = node.offsetTop; ++ next.set(name, top); ++ if (!moving) continue; ++ const before = tops.current.get(name); ++ if (before === void 0) { ++ node.animate([{ opacity: 0, filter: "blur(4px)" }, { opacity: 1, filter: "blur(0)" }], { duration: 180, easing: "ease-out" }); ++ } else if (Math.abs(before - top) > 0.5) { ++ node.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], { duration: SPRING_LIST.duration, easing: SPRING_LIST.easing }); ++ } ++ } ++ tops.current = next; ++ }, [key]); ++} ++function useMorph(box, key) { + const last = (0, import_react3.useRef)({ key, height: null }); + (0, import_react3.useLayoutEffect)(() => { - const node = box.current; - const state = last.current; - if (!node) { -@@ -1817,8 +1817,8 @@ function useMorph(box, key) { - }); - } - function useLinger(value, ms) { -- const [kept, setKept] = (0, import_react.useState)(value); -- (0, import_react.useEffect)(() => { ++ const node = box.current; ++ const state = last.current; ++ if (!node) { ++ last.current = { key, height: null }; ++ return; ++ } ++ const running = node.getAnimations().filter((animation2) => animation2.id === "bt-morph"); ++ if (key === state.key) { ++ if (running.length === 0) state.height = node.offsetHeight; ++ return; ++ } ++ const from = running.length ? node.offsetHeight : state.height; ++ for (const animation2 of running) animation2.cancel(); ++ const to = node.offsetHeight; ++ last.current = { key, height: to }; ++ if (from === null || Math.abs(from - to) < 1 || reducedMotion()) return; ++ node.dataset.morph = ""; ++ const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); ++ animation.onfinish = () => { ++ delete node.dataset.morph; ++ }; ++ animation.oncancel = () => { ++ if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; ++ }; ++ }); ++} ++function useLinger(value, ms) { + const [kept, setKept] = (0, import_react3.useState)(value); + (0, import_react3.useEffect)(() => { - if (value) { - setKept(value); - return void 0; ++ if (value) { ++ setKept(value); ++ return void 0; ++ } ++ const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); ++ return () => clearTimeout(timer); ++ }, [value]); ++ return [value || kept, !value && Boolean(kept)]; + } + + // src/client/styles.js @@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} @@ -2675,37 +3025,7 @@ diff --git a/client.js b/client.js - document.documentElement.removeEventListener("pointerleave", onLeave); - }; - }, [enabled]); -+// src/shared/face-title.js -+var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -+function isGenericFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return !n || n === "Face-to-face room"; -+} -+function formatFaceWhen(value) { -+ const date = value instanceof Date ? value : new Date(value); -+ if (!Number.isFinite(date.getTime())) return ""; -+ const hh = String(date.getHours()).padStart(2, "0"); -+ const mm = String(date.getMinutes()).padStart(2, "0"); -+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; -+} -+function isAutoFaceTitle(name) { -+ const n = String(name ?? "").trim(); -+ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); -+} -+function ownersFromMembers(members) { -+ const rows = []; -+ const seen = /* @__PURE__ */ new Set(); -+ for (const member of members ?? []) { -+ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); -+ rows.push({ owner, sort: handle || key }); -+ } -+ rows.sort((a, b) => a.sort.localeCompare(b.sort)); -+ return rows.slice(0, 3).map((row) => row.owner); - } +-} -var FIDGETS = ["hop", "sway", "stretch", "look"]; -function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot); @@ -2731,135 +3051,24 @@ diff --git a/client.js b/client.js - onAnimationEnd: poked ? (event) => { - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 -+function uniqueOwners(owners) { -+ const seen = /* @__PURE__ */ new Set(); -+ const out = []; -+ for (const raw of owners ?? []) { -+ const owner = String(raw ?? "").trim(); -+ const key = owner.toLowerCase(); -+ if (!owner || seen.has(key)) continue; -+ seen.add(key); -+ out.push(owner); -+ } -+ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); -+ return out.slice(0, 3); -+} -+function joinCodeOf(code) { -+ return String(code ?? "").replace(/\D/g, "").slice(0, 6); -+} -+var FACE_JOIN_WINDOW_MS = 10 * 6e4; -+function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { -+ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; -+ const deadline = Date.parse(expiresAt); -+ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); -+ return startedAt || void 0; -+} -+function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { -+ const named = String(name ?? "").trim(); -+ if (named && !isAutoFaceTitle(named)) return named; -+ const people2 = members?.length ? ownersFromMembers(members) : uniqueOwners(owners); -+ const join = joinCodeOf(code); -+ const when3 = formatFaceWhen(at) || formatFaceWhen(/* @__PURE__ */ new Date()); -+ const bits = ["Face-to-face"]; -+ if (people2.length) bits.push(people2.join(", ")); -+ bits.push(join.length === 6 ? join : when3); -+ return bits.join(" · "); -+} -+ -+// src/client/brand.js -+var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; -+var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; -+var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; -+function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { -+ return ``; -+} -+function brandTitle(title) { -+ const text = String(title ?? ""); -+ if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; -+ if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; -+ if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); -+ return text; -+} -+function sessionTabLabel(roster, sessionId) { -+ if (!roster || !sessionId) return ""; -+ const bot = roster.byId?.[sessionId]; -+ const room2 = roster.roomsById?.[sessionId]; -+ if (bot?.remote) { -+ return friendBotTitle(bot); -+ } -+ if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; -+ if (room2) return roomTitle(room2); -+ return bot?.name || ""; -+} -+function installTabTitle(sessions, roster, doc = globalThis.document) { -+ if (!doc || !sessions || !roster) return () => { -+ }; -+ const sync = () => { -+ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -+ const label = sessionTabLabel(roster.getSnapshot(), row?.id); -+ if (!label) return; -+ const next = brandTitle(`${label} — FlyBot`); -+ if (doc.title !== next) doc.title = next; -+ }; -+ sync(); -+ const offSessions = sessions.subscribe(sync); -+ const offRoster = roster.subscribe(sync); -+ return () => { -+ offSessions?.(); -+ offRoster?.(); - }; +- }; - if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); - const box = shapeOf(look.shape).size; - return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); - } +-} -function StarIcon({ size = 8 }) { - return (0, import_react2.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) - ); -+function installBrandChrome() { -+ if (typeof document === "undefined") return () => { -+ }; -+ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; -+ let link = document.getElementById("flybot-favicon"); -+ if (!link) { -+ link = document.createElement("link"); -+ link.id = "flybot-favicon"; -+ link.rel = "icon"; -+ link.type = "image/svg+xml"; -+ document.head.appendChild(link); -+ } -+ link.href = href; -+ const retitle = () => { -+ const next = brandTitle(document.title); -+ if (next !== document.title) document.title = next; -+ }; -+ retitle(); -+ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; -+ const node = document.querySelector("title") ?? document.head; -+ observer?.observe(node, { childList: true, characterData: true, subtree: true }); -+ return () => { -+ observer?.disconnect(); -+ link.remove(); -+ }; - } +-} -function AdminStar() { - return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); -+ -+// src/client/icons.js -+var import_react4 = require("react"); -+function PlusIcon() { -+ return (0, import_react4.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); - } +-} -function MainStar() { - return (0, import_react2.createElement)( -+function SearchIcon() { -+ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react4.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+} -+function PeopleIcon() { -+ return (0, import_react4.createElement)( - "svg", +- "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); @@ -2880,7 +3089,37 @@ diff --git a/client.js b/client.js - return () => clearTimeout(timer); - }, [state]); - return settled; --} ++// src/shared/face-title.js ++var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; ++function isGenericFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return !n || n === "Face-to-face room"; ++} ++function formatFaceWhen(value) { ++ const date = value instanceof Date ? value : new Date(value); ++ if (!Number.isFinite(date.getTime())) return ""; ++ const hh = String(date.getHours()).padStart(2, "0"); ++ const mm = String(date.getMinutes()).padStart(2, "0"); ++ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${hh}:${mm}`; ++} ++function isAutoFaceTitle(name) { ++ const n = String(name ?? "").trim(); ++ return isGenericFaceTitle(n) || n.startsWith("Face-to-face · "); ++} ++function ownersFromMembers(members) { ++ const rows = []; ++ const seen = /* @__PURE__ */ new Set(); ++ for (const member of members ?? []) { ++ const owner = String(member?.ownerName ?? member?.owner_name ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ const handle = String(member?.handle ?? member?.id ?? "").trim().toLowerCase(); ++ rows.push({ owner, sort: handle || key }); ++ } ++ rows.sort((a, b) => a.sort.localeCompare(b.sort)); ++ return rows.slice(0, 3).map((row) => row.owner); + } -function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { - const settled = useSettled(state); - if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); @@ -2894,7 +3133,40 @@ diff --git a/client.js b/client.js - badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, - badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null - ); --} ++function uniqueOwners(owners) { ++ const seen = /* @__PURE__ */ new Set(); ++ const out = []; ++ for (const raw of owners ?? []) { ++ const owner = String(raw ?? "").trim(); ++ const key = owner.toLowerCase(); ++ if (!owner || seen.has(key)) continue; ++ seen.add(key); ++ out.push(owner); ++ } ++ out.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); ++ return out.slice(0, 3); ++} ++function joinCodeOf(code) { ++ return String(code ?? "").replace(/\D/g, "").slice(0, 6); ++} ++var FACE_JOIN_WINDOW_MS = 10 * 6e4; ++function faceRoomCreatedAt({ createdAt, expiresAt, startedAt } = {}) { ++ if (createdAt && Number.isFinite(Date.parse(createdAt))) return createdAt; ++ const deadline = Date.parse(expiresAt); ++ if (Number.isFinite(deadline)) return new Date(deadline - FACE_JOIN_WINDOW_MS).toISOString(); ++ return startedAt || void 0; ++} ++function formatFaceRoomTitle({ name, owners, members, code, at } = {}) { ++ const named = String(name ?? "").trim(); ++ if (named && !isAutoFaceTitle(named)) return named; ++ const people2 = members?.length ? ownersFromMembers(members) : uniqueOwners(owners); ++ const join = joinCodeOf(code); ++ const when3 = formatFaceWhen(at) || formatFaceWhen(/* @__PURE__ */ new Date()); ++ const bits = ["Face-to-face"]; ++ if (people2.length) bits.push(people2.join(", ")); ++ bits.push(join.length === 6 ? join : when3); ++ return bits.join(" · "); + } -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]]; @@ -2902,10 +3174,34 @@ diff --git a/client.js b/client.js - const size = frame * 5 / 9; - const rest = frame - size; - return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; -- } ++ ++// src/client/brand.js ++var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; ++var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; ++var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; ++function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { ++ return ``; ++} ++function brandTitle(title) { ++ const text = String(title ?? ""); ++ if (text === "DeepSeek Harness" || text === "DS Bot") return "FlyBot"; ++ if (text.endsWith(" — DeepSeek Harness")) return `${text.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; ++ if (/\bDS Bot\b/.test(text)) return text.replace(/\bDS Bot\b/g, "FlyBot"); ++ return text; ++} ++function sessionTabLabel(roster, sessionId) { ++ if (!roster || !sessionId) return ""; ++ const bot = roster.byId?.[sessionId]; ++ const room2 = roster.roomsById?.[sessionId]; ++ if (bot?.remote) { ++ return friendBotTitle(bot); + } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; --} ++ if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; ++ if (room2) return roomTitle(room2); ++ return bot?.name || ""; + } -function RoomAvatar({ room: room2, roster, size = 36 }) { - const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); - const people = members.length === 1 ? [members[0], null] : members; @@ -2918,7 +3214,36 @@ diff --git a/client.js b/client.js - people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), - people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null - ); -- } ++function installTabTitle(sessions, roster, doc = globalThis.document) { ++ if (!doc || !sessions || !roster) return () => { ++ }; ++ const sync = () => { ++ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); ++ const label = sessionTabLabel(roster.getSnapshot(), row?.id); ++ if (!label) return; ++ const next = brandTitle(`${label} — FlyBot`); ++ if (doc.title !== next) doc.title = next; ++ }; ++ sync(); ++ const offSessions = sessions.subscribe(sync); ++ const offRoster = roster.subscribe(sync); ++ return () => { ++ offSessions?.(); ++ offRoster?.(); ++ }; ++} ++function installBrandChrome() { ++ if (typeof document === "undefined") return () => { ++ }; ++ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; ++ let link = document.getElementById("flybot-favicon"); ++ if (!link) { ++ link = document.createElement("link"); ++ link.id = "flybot-favicon"; ++ link.rel = "icon"; ++ link.type = "image/svg+xml"; ++ document.head.appendChild(link); + } - const seats = clusterSeats(Math.min(people.length, 4), size); - const overflow = people.length > 4 ? people.length - 3 : 0; - const gap = size / 14; @@ -2937,24 +3262,40 @@ diff --git a/client.js b/client.js - return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); --} -- --// src/client/icons.js ++ link.href = href; ++ const retitle = () => { ++ const next = brandTitle(document.title); ++ if (next !== document.title) document.title = next; ++ }; ++ retitle(); ++ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; ++ const node = document.querySelector("title") ?? document.head; ++ observer?.observe(node, { childList: true, characterData: true, subtree: true }); ++ return () => { ++ observer?.disconnect(); ++ link.remove(); ++ }; + } + + // src/client/icons.js -var import_react3 = require("react"); --function PlusIcon() { ++var import_react4 = require("react"); + function PlusIcon() { - return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function SearchIcon() { ++ return (0, import_react4.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + } + function SearchIcon() { - return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); --} --function PeopleIcon() { ++ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react4.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); + } + function PeopleIcon() { - return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, ++ return (0, import_react4.createElement)( + "svg", + { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), - (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), - (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react4.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), + (0, import_react4.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), + (0, import_react4.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) @@ -3948,7 +4289,7 @@ diff --git a/client.js b/client.js const close = () => setPanel(null); useDismiss(panel !== null, close, [tools, pop]); const open = (kind, event) => { -@@ -3865,25 +3865,25 @@ function MessageTools({ text, name, sessionId, react, actions }) { +@@ -3865,915 +3865,1954 @@ function MessageTools({ text, name, sessionId, react, actions }) { link.click(); setTimeout(() => URL.revokeObjectURL(url), 1e3); }; @@ -3980,35 +4321,32 @@ diff --git a/client.js b/client.js ), document.body) : null, - panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( + panel === "more" ? (0, import_react_dom.createPortal)((0, import_react8.createElement)( - "div", - { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, - item(t("Copy"), CopyIcon, copy2), -@@ -3892,11 +3892,12 @@ function MessageTools({ text, name, sessionId, react, actions }) { - ); - } - function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( ++ "div", ++ { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, ++ item(t("Copy"), CopyIcon, copy2), ++ item(t("Download"), DownloadIcon, download2) ++ ), document.body) : null ++ ); ++} ++function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react8.createElement)( - "details", - { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) ++ "details", ++ { className: "bt-think" }, + (0, import_react8.createElement)("summary", null, (0, import_react8.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react8.createElement)("div", { className: "bt-think-text" }, text) - ); - } - function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -@@ -3904,19 +3904,33 @@ function useFileMentions({ node, openFile, fileMentions, useTurnData }) { - const location2 = node.location; - const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; - const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { ++ ); ++} ++function useFileMentions({ node, openFile, fileMentions, useTurnData }) { ++ const tail = useTurnData?.("turn-tail"); ++ const location2 = node.location; ++ const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; ++ const seq = node.data.finalNode?.seq; + return (0, import_react8.useMemo)(() => { - if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; - return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; - }, [fileMentions, openFile, seq, tail, turn]); - } ++ if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; ++ return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; ++ }, [fileMentions, openFile, seq, tail, turn]); ++} +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; @@ -4020,45 +4358,41 @@ diff --git a/client.js b/client.js + if (texts2.every((text) => /^Not sent:/.test(text.trim()))) return void 0; + return (0, import_react8.createElement)("span", { className: "bt-lead", title: bot.name, "data-flybot": "friend-lead" }, (0, import_react8.createElement)(BotMark, { bot, size: 22 })); +} - function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { - const roster = useRoster((value) => value); - const data = node.data; - const steps = useTurnSteps(String(data.turn)); - const contexts = useTurnContext(String(data.turn)); - const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); ++function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { ++ const roster = useRoster((value) => value); ++ const data = node.data; ++ const steps = useTurnSteps(String(data.turn)); ++ const contexts = useTurnContext(String(data.turn)); ++ const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); + const held = groupPart === "reasoning" ? void 0 : heldReplyOf(roster.byId[sessionId], sessionId, data.turn); + const closing = useClosingText(node, useTurnData); - const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); ++ const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); + const person = typedBy(roster, sessionId, data.turn); + const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block, index) => index === 0 ? stripTrace(block.text) : block.text).filter((text) => text.trim() !== ""); - const running = data.status === "running"; - useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); - if (hidden) return null; -@@ -3924,13 +3924,14 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use - const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; - if (texts2.length === 0 && !reasoning && relays.length === 0) return null; - const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, ++ const running = data.status === "running"; ++ useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); ++ if (hidden) return null; ++ const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); ++ const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; ++ if (texts2.length === 0 && !reasoning && relays.length === 0) return null; ++ const selfId = roster.byId[sessionId]?.id ?? sessionId; + const bubbles = texts2.length ? person ? (0, import_react8.createElement)(PersonMessage, { name: person, texts: texts2, roster, selfId, actions, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi }) : (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions, lead: friendLead(roster.byId[sessionId], texts2) }) : null; + return (0, import_react8.createElement)( + import_react8.Fragment, - null, - relays.map((relay) => { - const source = relay.source; - const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { ++ null, ++ relays.map((relay) => { ++ const source = relay.source; ++ const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react8.createElement)(EventLine, { - key: relay.seq, - prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), - bots: [sender], -@@ -3940,79 +3940,248 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use - whole: true - }); - }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null ++ key: relay.seq, ++ prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), ++ bots: [sender], ++ onOpen: () => { ++ if (sender.id) actions.openExchange(selfId, sender.id); ++ }, ++ whole: true ++ }); ++ }), + reasoning ? (0, import_react8.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react8.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles + ); @@ -4081,43 +4415,33 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react8.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react8.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) - ); - } - function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { - if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( ++ ); ++} ++function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { ++ if (whole && bots.length === 1) { + return (0, import_react8.createElement)( - "div", - { className: "bt-event" }, -- (0, import_react6.createElement)( ++ "div", ++ { className: "bt-event" }, + (0, import_react8.createElement)( - "button", - { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) ++ "button", ++ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, + (0, import_react8.createElement)("span", null, prefix), + (0, import_react8.createElement)(BotMark, { bot: bots[0], size: 16 }), + (0, import_react8.createElement)("span", { className: "bt-event-name" }, bots[0].name) - ) - ); - } - const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); ++ ) ++ ); ++ } ++ const parts = []; + parts.push((0, import_react8.createElement)("span", { key: "p" }, prefix)); - bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( ++ bots.forEach((bot, index) => { + if (index > 0) parts.push((0, import_react8.createElement)("span", { key: `j${index}` }, joiner)); + parts.push((0, import_react8.createElement)( - "button", - { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), ++ "button", ++ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), - bot.name - )); - }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ bot.name ++ )); ++ }); + return (0, import_react8.createElement)("div", { className: "bt-event" }, parts); +} +var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); @@ -4132,9 +4456,7 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( ++} +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -4158,12 +4480,10 @@ diff --git a/client.js b/client.js + } + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react8.createElement)( - "button", - { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) ++ "button", ++ { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + (0, import_react8.createElement)(BotMark, { bot, size: 22 }) - ); -- return (0, import_react6.createElement)( ++ ); + const name = (0, import_react8.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, @@ -4171,9 +4491,8 @@ diff --git a/client.js b/client.js + room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + return (0, import_react8.createElement)( - "div", - { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", ++ { className: "bt-group-msg" }, + name, + (0, import_react8.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); @@ -4238,24 +4557,15 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + // A face room's header is the one name alone; the handle is on hover only (#457). + face ? (0, import_react8.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) }, title: handle }, name) : (0, import_react8.createElement)( - "span", - { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ "span", ++ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + name, + (0, import_react8.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react8.createElement)(GlobeMark), handle) - ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ++ ), + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null - ); - } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null ++ ); ++} +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -4268,17 +4578,8 @@ diff --git a/client.js b/client.js + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); - } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( ++ ); ++} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); + const [error, setError] = (0, import_react8.useState)(""); @@ -4327,211 +4628,261 @@ diff --git a/client.js b/client.js + if (!target) return null; + const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); + return (0, import_react8.createElement)( - "div", - { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ "div", ++ { className: "bt-event" }, + (0, import_react8.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react8.createElement)(MemoryIcon), label) - ); - } - function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); ++ ); ++} ++function PartLine({ time }) { + return (0, import_react8.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react8.createElement)("span", null, dayLabel(time)) : null); - } - var scrollParent = (node) => { - for (let at = node.parentElement; at; at = at.parentElement) { -@@ -4021,11 +4021,11 @@ var scrollParent = (node) => { - return null; - }; - function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); ++} ++var scrollParent = (node) => { ++ for (let at = node.parentElement; at; at = at.parentElement) { ++ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; ++ } ++ return null; ++}; ++function EarlierParts({ sessionId, roster, actions }) { + const [view, setView] = (0, import_react8.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react8.useRef)(null); + const busy = (0, import_react8.useRef)(false); + const alive = (0, import_react8.useRef)(true); + const restore = (0, import_react8.useRef)(null); - const load = (cursor) => { - if (busy.current) return; - busy.current = true; -@@ -4041,19 +4041,19 @@ function EarlierParts({ sessionId, roster, actions }) { - if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); - }); - }; -- (0, import_react6.useEffect)(() => { ++ const load = (cursor) => { ++ if (busy.current) return; ++ busy.current = true; ++ setView((current) => ({ ...current, loading: true, failed: false })); ++ actions.earlier(sessionId, cursor).then((page) => { ++ busy.current = false; ++ if (!alive.current) return; ++ const scroller = top.current && scrollParent(top.current); ++ if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; ++ setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); ++ }, () => { ++ busy.current = false; ++ if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); ++ }); ++ }; + (0, import_react8.useEffect)(() => { - alive.current = true; - load(void 0); - return () => { - alive.current = false; - }; - }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { ++ alive.current = true; ++ load(void 0); ++ return () => { ++ alive.current = false; ++ }; ++ }, [sessionId]); + (0, import_react8.useLayoutEffect)(() => { - const pending = restore.current; - restore.current = null; - if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; - }, [view.items]); -- (0, import_react6.useEffect)(() => { ++ const pending = restore.current; ++ restore.current = null; ++ if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; ++ }, [view.items]); + (0, import_react8.useEffect)(() => { - const node = top.current; - if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; - const observer = new IntersectionObserver((entries2) => { -@@ -4069,35 +4069,35 @@ function EarlierParts({ sessionId, roster, actions }) { - if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); - else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); - } -- return (0, import_react6.createElement)( ++ const node = top.current; ++ if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; ++ const observer = new IntersectionObserver((entries2) => { ++ if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); ++ }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); ++ observer.observe(node); ++ return () => observer.disconnect(); ++ }, [view.cursor, view.loading, view.failed]); ++ const selfId = roster.byId[sessionId]?.id; ++ const rows = []; ++ for (const item of view.items) { ++ const last = rows.at(-1); ++ if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); ++ else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); ++ } + return (0, import_react8.createElement)( - "div", - { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( ++ "div", ++ { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react8.createElement)( "div", - { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null +- ); +-} +-function ThinkingRow({ text }) { +- return (0, import_react6.createElement)( +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) ++ { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react8.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react8.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null - ), - rows.map((row, index) => { - const key = rows.length - index; - switch (row.kind) { - case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); ++ ), ++ rows.map((row, index) => { ++ const key = rows.length - index; ++ switch (row.kind) { ++ case "divider": + return (0, import_react8.createElement)(PartLine, { key, time: row.time }); - case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( ++ case "user": + return (0, import_react8.createElement)("div", { key, className: "bt-urow" }, (0, import_react8.createElement)( - "div", - { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null ++ "div", ++ { className: "bt-ububble" }, + row.text ? (0, import_react8.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, + row.images ? (0, import_react8.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null - )); - case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); ++ )); ++ case "answer": + return (0, import_react8.createElement)(AnsweredCell, { key, node: { data: row } }); - case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); ++ case "bot": + return (0, import_react8.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); - case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); ++ case "event": { + if (row.role === "report") return (0, import_react8.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); - const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; - const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { ++ const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; ++ const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); + return (0, import_react8.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { - if (selfId && other.id) actions.openExchange(selfId, other.id); - }, whole: true }); - } -@@ -4105,7 +4105,7 @@ function EarlierParts({ sessionId, roster, actions }) { - return null; - } - }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ if (selfId && other.id) actions.openExchange(selfId, other.id); ++ }, whole: true }); ++ } ++ default: ++ return null; ++ } ++ }), + (0, import_react8.createElement)(PartLine, { time: view.startedAt }) ); } - function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -@@ -4116,7 +4116,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us - const deferred = source.kind === "bot" && source.deferred === true; - const relayed = isRelayContext(data); - const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); +-} +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { ++function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; ++ const source = data.source !== null && typeof data.source === "object" ? data.source : {}; + const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; ++ const deferred = source.kind === "bot" && source.deferred === true; ++ const relayed = isRelayContext(data); ++ const turn = node.location?.turn?.turn ?? data.turn; + (0, import_react8.useLayoutEffect)(() => { - if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); - }, [relayed, sessionId, turn, source.senderSessionId]); - const order = useSessions((list) => { -@@ -4135,23 +4135,31 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us - return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; - }); - const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; ++ if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); ++ }, [relayed, sessionId, turn, source.senderSessionId]); ++ const order = useSessions((list) => { ++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; ++ return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; ++ }); ++ const peers = useTurnClock((state) => { ++ if (!relayed) return ""; ++ const turns = state[sessionId] ?? {}; ++ return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); ++ }); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const hiddenTurns = roster.exchangeTurns[sessionId]; ++ const unreadOpening = useTurnClock((state) => { ++ const first = openingTurn(state[sessionId], order, hiddenTurns); ++ return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; ++ }); ++ const body = triggerBody(); + return unreadOpening ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)("div", { className: "bt-open-new" }, (0, import_react8.createElement)(NewDivider), (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; - function triggerBody() { ++ function triggerBody() { + if (source.form === "remote-chat") return null; + if (source.shown === true) return null; + if (source.remote?.handle) { + const message = (0, import_react8.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react8.createElement)(import_react8.Fragment, null, (0, import_react8.createElement)(FirstContact, { source, roster, sessionId }), message) : message; + } - if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; ++ if (source.kind === "bot") { + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react8.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; + if (source.role === "mention") return (0, import_react8.createElement)("div", { className: "bt-event" }, (0, import_react8.createElement)("span", null, contentText(data.content))); - if (source.role === "secret" || deferred) return null; - if (source.role === "post") { - if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; ++ if (source.role === "secret" || deferred) return null; ++ if (source.role === "post") { ++ if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; + if (source.notice === true || source.senderSessionId === "face-notice") return null; - const room2 = roster.roomsById[sessionId]; - const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); ++ const room2 = roster.roomsById[sessionId]; ++ const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; + return (0, import_react8.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); - } - if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); ++ } ++ if (source.role === "report") { + return (0, import_react8.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); - } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); ++ } + if (source.role === "resume" || source.resumed === true) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); + if (source.role === "retry" || source.retried === true) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); - const run = relayed ? commRunOf( - order.split(",").filter(Boolean).map(Number), - Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -@@ -4165,7 +4165,7 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us - for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { - if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); - } ++ const run = relayed ? commRunOf( ++ order.split(",").filter(Boolean).map(Number), ++ Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), ++ turn, ++ hiddenTurns ++ ) : null; ++ if (run === "folded") return null; + const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; - return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, ++ const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); ++ const bots = [sender]; ++ for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { ++ if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); ++ } + return (0, import_react8.createElement)(EventLine, { - prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), - bots, - onOpen: (bot) => actions.openExchange(selfId, bot.id), -@@ -4174,24 +4174,24 @@ function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, us - } - if (source.kind === "schedule") { - const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); ++ prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), ++ bots, ++ onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null ++ } ++ if (source.kind === "schedule") { ++ const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react8.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); - } - if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); ++ } ++ if (source.kind === "user-question-reply") return null; + return (0, import_react8.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); - } - } - function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++ } ++} ++function NewDivider() { + return (0, import_react8.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react8.createElement)("span", null, t("New"))); - } - function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { - const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; -- (0, import_react6.useEffect)(() => { ++} ++function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { ++ const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react8.useEffect)(() => { - if (!open) turnProcess.setOpen(true); - }, [open, turnProcess]); - const turn = node?.location?.turn?.turn ?? node?.data?.turn; - const start = node?.location?.turn?.start?.time; - const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); -- (0, import_react6.useEffect)(() => { ++ if (!open) turnProcess.setOpen(true); ++ }, [open, turnProcess]); ++ const turn = node?.location?.turn?.turn ?? node?.data?.turn; ++ const start = node?.location?.turn?.start?.time; ++ const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); + (0, import_react8.useEffect)(() => { - actions.noteTurnTime(sessionId, turn, "first", start); - }, [sessionId, turn, start]); - const visit = useUi((state) => state.visits?.[sessionId]); -@@ -4199,11 +4199,32 @@ function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTu - const own = state[sessionId]?.[turn]; - return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; - }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; ++ actions.noteTurnTime(sessionId, turn, "first", start); ++ }, [sessionId, turn, start]); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const fresh = useTurnClock((state) => { ++ const own = state[sessionId]?.[turn]; ++ return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; ++ }); + return fresh ? (0, import_react8.createElement)(NewDivider) : null; - } - function Nothing() { - return null; - } ++} ++function Nothing() { ++ return null; ++} +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var isHiddenNotice = (block) => block?.type === "tool-removal" && (HIDDEN_REMOVED_TOOLS.has(block.toolName) || isConnectorTool(block.toolName) || isPluginTool(block.toolName)) || block?.type === "tool-addition" && (isConnectorTool(block.toolName) || isPluginTool(block.toolName)); @@ -4553,13 +4904,35 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react8.createElement)("div", { className: "bt-event" }, parts.join(" · ")); +} - var SEPARATOR_GAP_MS = 30 * 60 * 1e3; - function clockLabel(time, now = Date.now()) { - const date = new Date(time); -@@ -4233,10 +4233,19 @@ function openingTurn(turns, firstTurns, hiddenTurns) { - return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; - } - var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; ++var SEPARATOR_GAP_MS = 30 * 60 * 1e3; ++function clockLabel(time, now = Date.now()) { ++ const date = new Date(time); ++ const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); ++ const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); ++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); ++ if (days === 0) return t("Today {time}", { time: clock4 }); ++ if (days === 1) return t("Yesterday {time}", { time: clock4 }); ++ const sameYear = date.getFullYear() === new Date(now).getFullYear(); ++ return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++} ++function nextTurnStart(turns, turn) { ++ let next; ++ for (const key in turns) { ++ const candidate = Number(key); ++ if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; ++ } ++ return next === void 0 ? void 0 : turns[next].first; ++} ++function openingTurn(turns, firstTurns, hiddenTurns) { ++ if (!turns || !firstTurns) return null; ++ const start = nextTurnStart(turns, -Infinity); ++ if (start === void 0) return null; ++ const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); ++ const listed = firstTurns.split(",").map(Number); ++ const earlier = listed.filter((entry) => entry < turn); ++ return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++} ++var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { @@ -4569,172 +4942,350 @@ diff --git a/client.js b/client.js + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; - function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { - const turn = location2.turn; - const time = location2.end.time; -- (0, import_react6.useEffect)(() => { ++function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { ++ const turn = location2.turn; ++ const time = location2.end.time; + (0, import_react8.useEffect)(() => { - actions.noteTurnTime(sessionId, turn, "end", time); - }, [sessionId, turn, time]); - const label = useTurnClock((state) => { -@@ -4250,15 +4250,15 @@ function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions - return start !== void 0 && start > visit.seen && start <= visit.entered; - }); - if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, ++ actions.noteTurnTime(sessionId, turn, "end", time); ++ }, [sessionId, turn, time]); ++ const label = useTurnClock((state) => { ++ const start = nextTurnStart(state[sessionId] ?? {}, turn); ++ return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; ++ }); ++ const visit = useUi((state) => state.visits?.[sessionId]); ++ const fresh = useTurnClock((state) => { ++ if (typeof visit?.seen !== "number" || time > visit.seen) return false; ++ const start = nextTurnStart(state[sessionId] ?? {}, turn); ++ return start !== void 0 && start > visit.seen && start <= visit.entered; ++ }); ++ if (!label && !fresh) return null; + return (0, import_react8.createElement)( + import_react8.Fragment, - null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null ++ null, + label ? (0, import_react8.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react8.createElement)(NewDivider) : null ); } - function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); ++function Shimmer({ children }) { + return (0, import_react8.createElement)("span", { className: "bt-shimmer" }, children); ++} ++var PHRASES = { ++ thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], ++ working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] ++}; ++var TOOL_PHRASES = [ ++ [/web_?search|search_?web/i, "Searching online"], ++ [/fetch|browse|browser|web/i, "Reading a page"], ++ [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], ++ [/^(read|view|cat)\b|read_/i, "Opening a file"], ++ [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], ++ [/write|edit|patch|apply|replace/i, "Writing changes"], ++ [/image|photo|draw/i, "Working on an image"] ++]; ++var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; ++function toolActivity(name, arg, findBot, self) { ++ switch (name) { ++ case "ask_user": ++ return null; ++ case "request_secret": ++ return null; ++ case "list_secrets": ++ return { label: t("Checking the keys"), state: "searching" }; ++ case "ask_user_question": ++ return { label: t("Waiting for your answer"), state: "alert" }; ++ case "group_relay": ++ return { relay: true, state: "orbit" }; ++ case "message_bot": { ++ const target = findBot(arg("to")); ++ return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; ++ } ++ case "stop_bot": { ++ const target = findBot(arg("bot")); ++ return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; ++ } ++ case "create_bot": ++ return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; ++ case "update_bot": { ++ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); ++ return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; ++ } ++ case "list_bots": ++ return { label: t("Checking the team"), state: "searching" }; ++ case "read_group_chat": ++ return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; ++ case "read_own_chat": ++ return { label: t("Looking back through the chat"), state: "searching" }; ++ case "remember": ++ return { label: t("Saving to memory"), state: "working" }; ++ case "forget": ++ return { label: t("Updating memory"), state: "working" }; ++ case "recall": ++ return { label: t("Checking memory"), state: "searching" }; ++ default: ++ if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; ++ const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; ++ return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +-} +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( +- "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null +- ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) +- ); } - var PHRASES = { - thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], -@@ -4320,19 +4320,22 @@ function toolActivity(name, arg, findBot, self) { - } - function workPhrase(tool) { - if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- ); ++function workPhrase(tool) { ++ if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); -+} + } +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) +- ); +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; } - function useLiveNote(actions, sessionId, id, entry) { - const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); ++function useLiveNote(actions, sessionId, id, entry) { ++ const key = entry ? JSON.stringify(entry) : ""; + (0, import_react8.useEffect)(() => { - if (!key) return void 0; - actions.noteLive(sessionId, id, JSON.parse(key)); - return () => actions.noteLive(sessionId, id, null); - }, [sessionId, id, key]); ++ if (!key) return void 0; ++ actions.noteLive(sessionId, id, JSON.parse(key)); ++ return () => actions.noteLive(sessionId, id, null); ++ }, [sessionId, id, key]); } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; +- } +- return null; +-}; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +- }); +- }; - (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react8.useState)({ replies: [] }); + (0, import_react8.useEffect)(() => { - if (done) return void 0; - let stop = false; - let timer; -@@ -4341,10 +4341,10 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi - const result = await actions.roomProgress(sessionId); - if (stop) return; - const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; ++ if (done) return void 0; ++ let stop = false; ++ let timer; ++ let seen = ""; ++ const tick = async () => { ++ const result = await actions.roomProgress(sessionId); ++ if (stop) return; ++ const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; + const key = entry ? JSON.stringify(entry.replies) : ""; - if (entry && key !== seen) { - seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); ++ if (entry && key !== seen) { ++ seen = key; + setLive({ replies: entry.replies ?? [] }); - } - timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); ++ } ++ timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); ++ }; ++ void tick(); + return () => { +- alive.current = false; ++ stop = true; ++ clearTimeout(timer); }; -@@ -4362,71 +4362,86 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi - replies = []; - } +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); +- (0, import_react6.useEffect)(() => { +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); ++ }, [done, sessionId, callId]); ++ let replies = live.replies; ++ if (done) { ++ try { ++ replies = JSON.parse(contentText(content)).replies ?? []; ++ } catch { ++ replies = []; ++ } } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -+ if (replies.length === 0) return null; - const room2 = roster.roomsById[sessionId]; - return (0, import_react6.createElement)( ++ if (replies.length === 0) return null; ++ const room2 = roster.roomsById[sessionId]; + return (0, import_react8.createElement)( "div", - { className: "bt-group-replies" }, - replies.map((reply, index) => { - const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; - if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null +- ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( ++ { className: "bt-group-replies" }, ++ replies.map((reply, index) => { ++ const stored = roster.byId[reply.botId]; ++ const resolved = reply.remote ? stored : rosterBot(roster, reply.botId) ?? stored; ++ const bot = resolved ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; ++ if (reply.kind === "user") { + const who = reply.name && reply.name !== "User" ? reply.name : reply.authorId; + if (reply.other) { + return (0, import_react8.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } -+ return (0, import_react8.createElement)( - "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); ++ return (0, import_react8.createElement)( ++ "div", + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react8.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react8.createElement)( -+ "div", -+ { className: "bt-ububble" }, + "div", + { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; + (0, import_react8.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) + ), + room2?.origin === "face-to-face" ? (0, import_react8.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react8.createElement)(PersonMark, { name: who || void 0 })) : null + ); - } - if (reply.kind === "failed") { -- return (0, import_react6.createElement)( ++ } ++ if (reply.kind === "failed") { + return (0, import_react8.createElement)( - "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, ++ "div", + { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status" }, - t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) - ); - } - if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( ++ t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) ++ ); ++ } ++ if (reply.kind === "lookup") { + return (0, import_react8.createElement)( - "div", - { key: `${reply.botId}-${index}`, className: "bt-event" }, - reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) - ); - } - if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); ++ "div", ++ { key: `${reply.botId}-${index}`, className: "bt-event" }, ++ reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) ++ ); ++ } ++ if (reply.kind === "missed") { + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); - } - if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); ++ } ++ if (reply.kind === "resumed") { + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); - } - if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); ++ } ++ if (reply.kind === "cut") { + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); - } - if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( ++ } ++ if (reply.kind === "approval" || reply.kind === "working") { + return (0, import_react8.createElement)( - "div", - { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( ++ "div", ++ { key: `${reply.botId}-${index}`, className: "bt-event" }, + (0, import_react8.createElement)( - "button", - { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) ++ "button", ++ { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, + (0, import_react8.createElement)(BotMark, { bot, size: 16 }), + (0, import_react8.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) - ) - ); - } - if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); ++ ) ++ ); ++ } ++ if (reply.kind === "stopped") { + return (0, import_react8.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( -- "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react8.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -4753,150 +5304,188 @@ diff --git a/client.js b/client.js + }); + } + return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); - }) - ); - } -@@ -4435,52 +4435,52 @@ var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); - function callTree(root) { - const calls = []; - const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; ++ }) ++ ); ++} ++var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); ++var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); ++function callTree(root) { ++ const calls = []; ++ const seen = /* @__PURE__ */ new Set(); + const visit = (call4) => { + const id = call4.callId ?? call4; - if (seen.has(id)) return; - seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); ++ if (seen.has(id)) return; ++ seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); - }; - visit(root); - return calls; - } - function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); -- (0, import_react6.useLayoutEffect)(() => { ++ }; ++ visit(root); ++ return calls; ++} ++function useShownCards(actions, sessionId, callIds) { ++ const key = JSON.stringify(callIds); + (0, import_react8.useLayoutEffect)(() => { - const ids = JSON.parse(key); - if (ids.length === 0 || !actions.noteCards) return void 0; - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); - } --function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { -- const tool = toolName(call2); -- const done = call2.kind === "tool-result"; -- const { text: arg, value: argValue } = toolArgs(call2); ++ const ids = JSON.parse(key); ++ if (ids.length === 0 || !actions.noteCards) return void 0; ++ actions.noteCards(sessionId, ids, 1); ++ return () => actions.noteCards(sessionId, ids, -1); ++ }, [sessionId, key]); ++} +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; + const { text: arg, value: argValue } = toolArgs(call4); - const self = roster.byId[sessionId]; - switch (tool) { - case "message_bot": { - const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); ++ const self = roster.byId[sessionId]; ++ switch (tool) { ++ case "message_bot": { ++ const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react8.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); - } - case "stop_bot": { - const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; -- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); -- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); ++ } ++ case "stop_bot": { ++ const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react8.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); + return (0, import_react8.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); - } - case "create_bot": { - const created = findBot(arg("name")); - if (!done || created === void 0) return null; -- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); ++ } ++ case "create_bot": { ++ const created = findBot(arg("name")); ++ if (!done || created === void 0) return null; + return (0, import_react8.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); - } - case "update_bot": { - const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; ++ } ++ case "update_bot": { ++ const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); + return done && target ? (0, import_react8.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; - } - case "group_relay": -- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); ++ } ++ case "group_relay": + return (0, import_react8.createElement)(GroupReplies, { sessionId, callId: call4.callId, done, content: call4.content, roster, actions, useUi }); - case "create_group": - case "update_group": - case "delete_group": -@@ -4492,64 +4492,72 @@ function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot - post_to_group: ["Posted in", "Couldn't post in the group"] - }[tool]; - if (!done) return null; -- const result = contentText(call2.content); ++ case "create_group": ++ case "update_group": ++ case "delete_group": ++ case "post_to_group": { ++ const verbs = { ++ create_group: ["Created group", "Couldn't create the group"], ++ update_group: ["Updated group", "Couldn't update the group"], ++ delete_group: ["Deleted group", "Couldn't delete the group"], ++ post_to_group: ["Posted in", "Couldn't post in the group"] ++ }[tool]; ++ if (!done) return null; + const result = contentText(call4.content); - const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); -- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); ++ const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); + if (call4.isError || !named) return (0, import_react8.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); - const name = named[2]; - const room2 = roster.rooms.find((entry) => entry.name === name); -- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); ++ const name = named[2]; ++ const room2 = roster.rooms.find((entry) => entry.name === name); + return (0, import_react8.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); - } - case "ask_user": { - const question = roster.questions?.[sessionId]; -- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); -- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); ++ } ++ case "ask_user": { ++ const question = roster.questions?.[sessionId]; + if (!question || question.callId !== call4.callId) return (0, import_react8.createElement)("span", { className: "bt-q-gone", hidden: true }); + return (0, import_react8.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); - } - case "request_secret": -- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); ++ } ++ case "request_secret": + return (0, import_react8.createElement)(SecretToolCell, { sessionId, callId: call4.callId, name: arg("name"), done, content: call4.content, roster, actions }); - case "ask_user_question": { - if (!done) return null; - let parsed = null; - try { -- parsed = JSON.parse(contentText(call2.content)); ++ case "ask_user_question": { ++ if (!done) return null; ++ let parsed = null; ++ try { + parsed = JSON.parse(contentText(call4.content)); - } catch { - parsed = null; ++ } catch { ++ parsed = null; } - const answer = parsed?.answers?.[0]; - const question = argValue("questions")?.[0]; - if (!answer) return null; -- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) +- ); ++ const answer = parsed?.answers?.[0]; ++ const question = argValue("questions")?.[0]; ++ if (!answer) return null; + return (0, import_react8.createElement)("div", { className: "bt-q-inline" }, (0, import_react8.createElement)(SettledQuestion, { question, answer })); - } - case "read_group_chat": { - if (!done) return null; - const room2 = roster.rooms.find((entry) => entry.name === arg("group")); -- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++ } ++ case "read_group_chat": { ++ if (!done) return null; ++ const room2 = roster.rooms.find((entry) => entry.name === arg("group")); + return room2 ? (0, import_react8.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; - } - case "remember": - case "forget": -- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; ++ } ++ case "remember": ++ case "forget": + return done && !call4.isError ? (0, import_react8.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; - default: - return null; - } ++ default: ++ return null; ++ } } --function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { const roster = useRoster((value) => value); - const root = node.data.root; - const turn = node.location?.turn?.turn ?? root.turn; - const hidden = isHiddenTurn(roster, sessionId, turn); - const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); - const calls = hidden ? [] : callTree(root); -- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); -- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; +- }); +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); +- } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); ++ const root = node.data.root; ++ const turn = node.location?.turn?.turn ?? root.turn; ++ const hidden = isHiddenTurn(roster, sessionId, turn); ++ const findBot = (nameOrId) => roster.byId[nameOrId] ?? botByName(roster.bots, nameOrId); ++ const calls = hidden ? [] : callTree(root); + const drawn = calls.filter((call4) => DRAWN.has(toolName(call4))); + const running = root.kind === "tool-result" ? void 0 : calls.findLast((call4) => call4.kind !== "tool-result"); - const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; - useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); -- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); ++ const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; ++ useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); + useShownCards(actions, sessionId, drawn.filter((call4) => CARDS.has(toolName(call4)) && call4.callId).map((call4) => call4.callId)); - if (drawn.length === 0) return null; -- const env = { sessionId, roster, actions, useUi, useWork, findBot }; -- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); ++ if (drawn.length === 0) return null; + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react8.createElement)(import_react8.Fragment, null, drawn.map((call4, index) => (0, import_react8.createElement)(import_react8.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); +} @@ -4907,21 +5496,19 @@ diff --git a/client.js b/client.js + const at = roster?.remoteAwaiting?.[sessionId]; + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; - } - - // src/client/activity-row.js --var import_react7 = require("react"); ++} ++ ++// src/client/activity-row.js +var import_react9 = require("react"); - var import_react_dom2 = require("react-dom"); - var PHRASE_HOLD_MS = 800; - var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); -@@ -4559,9 +4559,71 @@ function elapsedLabel(ms) { - const hours = Math.floor(minutes / 60); - return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; - } --function useRunningHost(active) { -- const [host, setHost] = (0, import_react7.useState)(null); -- (0, import_react7.useEffect)(() => { ++var import_react_dom2 = require("react-dom"); ++var PHRASE_HOLD_MS = 800; ++var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); ++function elapsedLabel(ms) { ++ const minutes = Math.floor(ms / 6e4); ++ if (minutes < 60) return `${Math.max(1, minutes)}m`; ++ const hours = Math.floor(minutes / 60); ++ return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; ++} +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -4961,11 +5548,17 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; -+ } + } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); -+ } + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } + if (sendTurn.current < 0) return void 0; + let frame = 0; + const check = () => { @@ -4983,49 +5576,111 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; -+} + } +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react9.useState)(null); + (0, import_react9.useEffect)(() => { - if (!active) { - setHost(null); - return void 0; -@@ -4569,7 +4569,7 @@ function useRunningHost(active) { - let frame = 0; - const find = () => { - frame = 0; -- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); ++ if (!active) { ++ setHost(null); ++ return void 0; ++ } ++ let frame = 0; ++ const find = () => { ++ frame = 0; + const node = findHost(where); - setHost((prev) => prev === node ? prev : node); - }; - const observer = new MutationObserver(() => { -@@ -4581,13 +4581,13 @@ function useRunningHost(active) { - cancelAnimationFrame(frame); - observer.disconnect(); - }; -- }, [active]); ++ setHost((prev) => prev === node ? prev : node); ++ }; ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(find); ++ }); ++ observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); ++ find(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; + }, [active, where]); - return host; ++ return host; } - function useRowEntrances(replacing) { -- const check = (0, import_react7.useRef)(replacing); +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; ++function useRowEntrances(replacing) { + const check = (0, import_react9.useRef)(replacing); - check.current = replacing; -- (0, import_react7.useEffect)(() => { ++ check.current = replacing; + (0, import_react9.useEffect)(() => { - const seen = /* @__PURE__ */ new WeakSet(); - const started = Date.now(); - let column = null; -@@ -4638,21 +4638,30 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us - const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); - const bot = roster.byId[sessionId]; - const room2 = roster.roomsById[sessionId]; -- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); ++ const seen = /* @__PURE__ */ new WeakSet(); ++ const started = Date.now(); ++ let column = null; ++ let frame = 0; ++ const settle = (event) => { ++ if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; ++ delete event.currentTarget.dataset.btEnter; ++ event.currentTarget.removeEventListener("animationend", settle); ++ }; ++ const scan = () => { ++ frame = 0; ++ const next = document.querySelector("[data-chat-flow]"); ++ if (next !== column) { ++ observer.disconnect(); ++ column = next; ++ if (column) observer.observe(column, { childList: true, subtree: true }); ++ } ++ if (!column) return; ++ const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; ++ let last = -1; ++ items.forEach((item, index) => { ++ if (seen.has(item)) last = index; ++ }); ++ const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); ++ for (const item of fresh) seen.add(item); ++ if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; ++ for (const item of fresh) { ++ if (items.indexOf(item) < last) continue; ++ const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); ++ item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; ++ item.addEventListener("animationend", settle); ++ } ++ }; ++ const observer = new MutationObserver(() => { ++ if (!frame) frame = requestAnimationFrame(scan); ++ }); ++ scan(); ++ return () => { ++ cancelAnimationFrame(frame); ++ observer.disconnect(); ++ }; ++ }, []); + } +-function Nothing() { +- return null; ++function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { ++ const roster = useRoster((value) => value); ++ const running = useSessionStatus((map) => map.get(sessionId)?.running === true); ++ const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); ++ const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); ++ const bot = roster.byId[sessionId]; ++ const room2 = roster.roomsById[sessionId]; + const entries2 = (0, import_react9.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); - const streaming = entries2.some((entry) => entry.text); - const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); - const relaying = tools.some((entry) => entry.relay); -- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); ++ const streaming = entries2.some((entry) => entry.text); ++ const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); ++ const relaying = tools.some((entry) => entry.relay); + const awaiting = useAwaitingReply(roster, sessionId); + const sendRun = (0, import_react9.useRef)(false); + if (!running) sendRun.current = false; @@ -5036,47 +5691,36 @@ diff --git a/client.js b/client.js + const bridging = bot?.remote !== void 0 && !streaming && lastRun.current.running && lastRun.current.send && !running; + lastRun.current = { running, send: sendRun.current }; + const steady = bridging; - const tool = tools.at(-1); -- const inputRef = (0, import_react7.useRef)(0); ++ const tool = tools.at(-1); + const inputRef = (0, import_react9.useRef)(0); - const inputAt = Math.max(inputRef.current, pendingInput); - inputRef.current = inputAt; - const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -- const [shown, setShown] = (0, import_react7.useState)(null); ++ const inputAt = Math.max(inputRef.current, pendingInput); ++ inputRef.current = inputAt; ++ const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; + const [shown, setShown] = (0, import_react9.useState)(null); - const wantKey = want?.key ?? null; -- const markState = (0, import_react7.useRef)("thinking"); ++ const wantKey = want?.key ?? null; + const markState = (0, import_react9.useRef)("thinking"); - if (want) markState.current = want.state; -- (0, import_react7.useEffect)(() => { ++ if (want) markState.current = want.state; + (0, import_react9.useEffect)(() => { - if (wantKey === null || shown?.key === wantKey) return void 0; - const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); - const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -@@ -4663,24 +4663,29 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us - const timer = setTimeout(make, wait); - return () => clearTimeout(timer); - }, [wantKey, shown]); -- const [phase, setPhase] = (0, import_react7.useState)(null); -- const endedAt = (0, import_react7.useRef)(0); -- const shift = (0, import_react7.useRef)(null); -- const leaving = (0, import_react7.useRef)(false); ++ if (wantKey === null || shown?.key === wantKey) return void 0; ++ const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); ++ const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; ++ if (wait <= 0) { ++ make(); ++ return void 0; ++ } ++ const timer = setTimeout(make, wait); ++ return () => clearTimeout(timer); ++ }, [wantKey, shown]); + const [phase, setPhase] = (0, import_react9.useState)(null); + const endedAt = (0, import_react9.useRef)(0); + const shift = (0, import_react9.useRef)(null); + const leaving = (0, import_react9.useRef)(false); - leaving.current = phase !== null && !visible; -- (0, import_react7.useEffect)(() => { ++ leaving.current = phase !== null && !visible; + (0, import_react9.useEffect)(() => { - if (visible) { - setPhase("in"); - return void 0; - } -- setPhase((current) => current === "in" ? "out" : current); -- const timer = setTimeout(() => { -- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -- setPhase((current) => current === "out" ? null : current); -- }, 140); ++ if (visible) { ++ setPhase("in"); ++ return void 0; ++ } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -5087,31 +5731,29 @@ diff --git a/client.js b/client.js + }; + if (steady) timer = setTimeout(leave, REMOTE_GRACE_MS); + else leave(); - return () => clearTimeout(timer); - }, [visible]); -- (0, import_react7.useLayoutEffect)(() => { ++ return () => clearTimeout(timer); ++ }, [visible]); + (0, import_react9.useLayoutEffect)(() => { - if (phase !== null) return; - endedAt.current = Date.now(); - setShown(null); -@@ -4691,31 +4691,37 @@ function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, us - const delta = before - scroller.scrollTop; - if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); - }, [phase]); -- (0, import_react7.useEffect)(() => { ++ if (phase !== null) return; ++ endedAt.current = Date.now(); ++ setShown(null); ++ const before = shift.current; ++ shift.current = null; ++ const scroller = document.querySelector("[data-conversation-scroll]"); ++ if (before === null || !scroller || reducedMotion()) return; ++ const delta = before - scroller.scrollTop; ++ if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); ++ }, [phase]); + (0, import_react9.useEffect)(() => { - if (!running) actions.clearLive(sessionId); - }, [running, sessionId]); - useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); -- const [now, setNow] = (0, import_react7.useState)(Date.now); -- (0, import_react7.useEffect)(() => { ++ if (!running) actions.clearLive(sessionId); ++ }, [running, sessionId]); ++ useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); + const [now, setNow] = (0, import_react9.useState)(Date.now); + (0, import_react9.useEffect)(() => { - if (phase !== "in") return void 0; - const timer = setInterval(() => setNow(Date.now()), 1e4); - return () => clearInterval(timer); - }, [phase]); -- const host = useRunningHost(running); ++ if (phase !== "in") return void 0; ++ const timer = setInterval(() => setNow(Date.now()), 1e4); ++ return () => clearInterval(timer); ++ }, [phase]); + const remote = bot?.remote !== void 0; + const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); + const drawn = (0, import_react9.useRef)(false); @@ -5119,42 +5761,52 @@ diff --git a/client.js b/client.js + (0, import_react9.useEffect)(() => { + drawn.current = Boolean(host && phase !== null && shown); + }, [host, phase, shown]); - if (!host || phase === null || !shown) return null; - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; -- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( ++ if (!host || phase === null || !shown) return null; ++ const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react9.createElement)( - "div", -- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, -- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -- (0, import_react7.createElement)( ++ "div", + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "aria-hidden": true }, + bot ? (0, import_react9.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react9.createElement)( - "span", - { className: "bt-typing-text" }, -- (0, import_react7.createElement)( ++ "span", ++ { className: "bt-typing-text" }, + (0, import_react9.createElement)( - "span", - { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), -- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null ++ "span", ++ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react9.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react9.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null - ) - ) - ), host); -@@ -4731,648 +4731,1453 @@ function showsDshRow(dsh, app) { - return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++ ) ++ ) ++ ), host); } - --// src/client/sidebar.js --var import_react8 = require("react"); --var import_react_dom3 = require("react-dom"); --var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); --function useMarkedUnread(id, active, marked, actions) { -- (0, import_react8.useEffect)(() => { -- if (active && marked) actions.clearUnread(id); -- }, [active, marked]); +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++ ++// src/client/dsh-offer.js ++var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); ++var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; ++var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); ++function showsDshRow(dsh, app) { ++ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; ++ if (dsh.kind === "npm") return true; ++ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); + } +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +- } +- return next === void 0 ? void 0 : turns[next].first; ++ +// src/client/overlay-hooks.js +var import_react10 = require("react"); +var escapeLayers = []; @@ -5173,57 +5825,14 @@ diff --git a/client.js b/client.js + }; + }, []); } --var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); --var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; --var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; --var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { -- const [id, tool] = key.split("|"); -- return { id, tool: tool || void 0 }; --}); --var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); --function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { -- const id = bot.id; -- const part = currentPart(bot); -- const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part])); -- 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; -- useMarkedUnread(id, active, marked, actions); -- const main = isMainOf(roster, id); -- const tile = roster.mainBotId === id; -- const approval = useApproval(useWork, (entry) => entry.botId === id); -- const alert = roster.questions?.[part] !== void 0 || approval; -- const doing = useActivity((map) => map[id]); -- const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); -- const away = !running && groups.length > 0; -- const state = botState(running || away, doing, alert); -- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; -- if (bot.hidden && !active && !main) return null; -- const onContextMenu = (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); -- }; -- const pinned = bot.pinned === true && !tile; -- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -- if (tile && wide) { -- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-tile", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: preview || bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu -- }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), -- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), -- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null -- )); -- } -- return (0, import_react8.createElement)( +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react10.useState)(280); + (0, import_react10.useLayoutEffect)(() => { @@ -5231,7 +5840,23 @@ diff --git a/client.js b/client.js + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; -+} + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; + +// src/client/member-status.js +var import_react11 = require("react"); @@ -5253,24 +5878,200 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; -+ }); -+} + }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +- ); +-} +-function Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); + } +-var PHRASES = { +- thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], +- working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] +-}; +-var TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +- } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +- } +function busyMembers(room2, work, roster) { + const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); + return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); -+} + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; -+} + } +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); +function botGroupWork(botId, work, roster) { + return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { + const entry = entries2.find((item) => item.botId === botId); + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); -+} + } +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); +- (0, import_react6.useEffect)(() => { +- if (done) return void 0; +- let stop = false; +- let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +- }; +- void tick(); +- return () => { +- stop = true; +- clearTimeout(timer); +- }; +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; +- } +- } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); +- } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); +- } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); +- } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); +- }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( +- "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); +- }) +- ); +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -5279,123 +6080,169 @@ diff --git a/client.js b/client.js + const first = workers[0]; + const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; + return { waiting, doing }; -+} + } +-var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +-var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +-function callTree(root) { +- const calls = []; +- const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); +- }; +- visit(root); +- return calls; +function Face({ entry, size }) { + return (0, import_react11.createElement)( + "span", + { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, + (0, import_react11.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); -+} + } +-function useShownCards(actions, sessionId, callIds) { +- const key = JSON.stringify(callIds); +- (0, import_react6.useLayoutEffect)(() => { +- const ids = JSON.parse(key); +- if (ids.length === 0 || !actions.noteCards) return void 0; +- actions.noteCards(sessionId, ids, 1); +- return () => actions.noteCards(sessionId, ids, -1); +- }, [sessionId, key]); +var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; +function WorkRow({ bot, tool, step, label, at, now, onOpen }) { + const text = `${label} · ${workText(tool, step)}`; + const elapsed = elapsedSince(now, at); + return (0, import_react11.createElement)( - "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-bot-id": id, -- title: wide ? void 0 : bot.name, -- onClick: () => actions.openSession(id), -- onContextMenu -- }, -- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ "button", + { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react11.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), + (0, import_react11.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react11.createElement)(Shimmer, null, text)), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); ++ ); } --function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { -- const id = room2.id; -- const fallback = useSessions((list) => previewOf(list, id)); -- const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback; -- const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -- const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; -- useMarkedUnread(id, active, marked, actions); -- const approval = useApproval(useWork, (entry) => entry.roomId === id); -- const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); -- const first = roster.byId[members[0]?.id]; -- const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; -- if (room2.hidden && !active) return null; -- const pinned = room2.pinned === true; -- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); -- return (0, import_react8.createElement)( +-function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { +- const tool = toolName(call2); +- const done = call2.kind === "tool-result"; +- const { text: arg, value: argValue } = toolArgs(call2); +- const self = roster.byId[sessionId]; +- switch (tool) { +- case "message_bot": { +- const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "stop_bot": { +- const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; +- if (done && call2.isError) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); +- return (0, import_react6.createElement)(EventLine, { prefix: t("Stopped"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); +- } +- case "create_bot": { +- const created = findBot(arg("name")); +- if (!done || created === void 0) return null; +- return (0, import_react6.createElement)(EventLine, { prefix: t("Created"), bots: [created], onOpen: (bot) => actions.openSession(bot.id) }); +- } +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return done && target ? (0, import_react6.createElement)(EventLine, { prefix: t("Updated"), bots: [target], onOpen: (bot) => actions.openSession(bot.id) }) : null; +- } +- case "group_relay": +- return (0, import_react6.createElement)(GroupReplies, { sessionId, callId: call2.callId, done, content: call2.content, roster, actions, useUi, useWork }); +- case "create_group": +- case "update_group": +- case "delete_group": +- case "post_to_group": { +- const verbs = { +- create_group: ["Created group", "Couldn't create the group"], +- update_group: ["Updated group", "Couldn't update the group"], +- delete_group: ["Deleted group", "Couldn't delete the group"], +- post_to_group: ["Posted in", "Couldn't post in the group"] +- }[tool]; +- if (!done) return null; +- const result = contentText(call2.content); +- const named = /^(Created group|Updated|Nothing changed in|Deleted|Posted in) "([^"]+)"/.exec(result); +- if (call2.isError || !named) return (0, import_react6.createElement)("div", { className: "bt-event", title: result }, t(verbs[1])); +- const name = named[2]; +- const room2 = roster.rooms.find((entry) => entry.name === name); +- return (0, import_react6.createElement)(GroupEvent, { prefix: t(verbs[0]), room: tool === "delete_group" ? void 0 : room2, name, roster, actions }); +- } +- case "ask_user": { +- const question = roster.questions?.[sessionId]; +- if (!question || question.callId !== call2.callId) return (0, import_react6.createElement)("span", { className: "bt-q-gone", hidden: true }); +- return (0, import_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: true }); +- } +- case "request_secret": +- return (0, import_react6.createElement)(SecretToolCell, { sessionId, callId: call2.callId, name: arg("name"), done, content: call2.content, roster, actions }); +- case "ask_user_question": { +- if (!done) return null; +- let parsed = null; +- try { +- parsed = JSON.parse(contentText(call2.content)); +- } catch { +- parsed = null; +- } +- const answer = parsed?.answers?.[0]; +- const question = argValue("questions")?.[0]; +- if (!answer) return null; +- return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, (0, import_react6.createElement)(SettledQuestion, { question, answer })); +- } +- case "read_group_chat": { +- if (!done) return null; +- const room2 = roster.rooms.find((entry) => entry.name === arg("group")); +- return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; +- } +- case "remember": +- case "forget": +- return done && !call2.isError ? (0, import_react6.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call2.content), roster, actions, findBot }) : null; +- default: +- return null; +- } +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; + return (0, import_react11.createElement)( - "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-room-id": id, -- onClick: () => actions.openSession(id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), -- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, -- wide ? (0, import_react8.createElement)( ++ "button", + { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, + (0, import_react11.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react11.createElement)(Face, { key: entry.id, entry, size: 16 }))), + (0, import_react11.createElement)( - "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), -- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ "span", + { className: "bt-mw-row-text" }, + waiting ? (0, import_react11.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react11.createElement)(Shimmer, null, doing) : null + ), + elapsed ? (0, import_react11.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); ++ ); } --function AgentSessionRow({ session, agent, actions, renaming, index }) { -- const input = (0, import_react8.useRef)(null); -- (0, import_react8.useLayoutEffect)(() => { -- if (!renaming) return; -- input.current?.focus(); -- input.current?.select(); -- }, [renaming]); -- if (renaming) { -- const commit = (keep) => { -- const value = input.current?.value ?? ""; -- if (!keep || value.trim() === "" || value.trim() === session.title) { -- actions.endAgentRename(); -- return; -- } -- void (async () => { -- try { -- await actions.renameAgentSession(agent.id, session.id, value); -- actions.endAgentRename(); -- } catch (error) { -- actions.reportError(error, "agent-rename"); -- } -- })(); -- }; -- return (0, import_react8.createElement)( +-function ToolCell({ node, sessionId, useRoster, useUi, useWork, actions }) { +function MemberDock({ sessionId, useRoster, useWork, actions }) { -+ const roster = useRoster((value) => value); + const roster = useRoster((value) => value); +- const root = node.data.root; +- const turn = node.location?.turn?.turn ?? root.turn; +- const hidden = isHiddenTurn(roster, sessionId, turn); +- const findBot = (nameOrId) => roster.byId[nameOrId] ?? roster.bots.find((bot) => bot.name.toLowerCase() === String(nameOrId ?? "").toLowerCase()); +- const calls = hidden ? [] : callTree(root); +- const drawn = calls.filter((call2) => DRAWN.has(toolName(call2))); +- const running = root.kind === "tool-result" ? void 0 : calls.findLast((call2) => call2.kind !== "tool-result"); +- const note = running ? toolActivity(toolName(running), toolArgs(running).text, findBot, roster.byId[sessionId]) : null; +- useLiveNote(actions, sessionId, `tool:${root.callId ?? node.key}`, note); +- useShownCards(actions, sessionId, drawn.filter((call2) => CARDS.has(toolName(call2)) && call2.callId).map((call2) => call2.callId)); +- if (drawn.length === 0) return null; +- const env = { sessionId, roster, actions, useUi, useWork, findBot }; +- return (0, import_react6.createElement)(import_react6.Fragment, null, drawn.map((call2, index) => (0, import_react6.createElement)(import_react6.Fragment, { key: call2.callId ?? index }, renderCall(call2, env)))); +-} +- +-// src/client/activity-row.js +-var import_react7 = require("react"); +-var import_react_dom2 = require("react-dom"); +-var PHRASE_HOLD_MS = 800; +-var drawPhrase = (pool) => t(pool[Math.min(pool.length - 1, Math.floor(Math.random() * pool.length))]); +-function elapsedLabel(ms) { +- const minutes = Math.floor(ms / 6e4); +- if (minutes < 60) return `${Math.max(1, minutes)}m`; +- const hours = Math.floor(minutes / 60); +- return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; + const work = useWork((value) => value); + const room2 = roster.roomsById[sessionId]; + const bot = room2 ? void 0 : roster.byId[sessionId]; @@ -5405,47 +6252,18 @@ diff --git a/client.js b/client.js + const shown = busy.length > 0 || groups.length > 0; + const now = useNow(shown); + const ref = (0, import_react11.useRef)(null); -+ useFlowColumn(ref, shown, sessionId); -+ if (!shown) return null; -+ if (room2) { -+ const { rows, rest } = splitBusy(busy); -+ const open = () => actions.showDetails(sessionId); -+ return (0, import_react11.createElement)( - "div", -- { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, -- (0, import_react8.createElement)("input", { -- ref: input, -- defaultValue: session.title, -- "aria-label": t("Rename"), -- onKeyDown: (event) => { -- if (event.nativeEvent.isComposing) return; -- if (event.key === "Enter") commit(true); -- else if (event.key === "Escape") commit(false); -- }, -- onBlur: () => commit(true) -- }) -+ { ref, className: "bt-mw-dock-stack" }, -+ rows.map((entry) => (0, import_react11.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), -+ rest.length > 0 ? (0, import_react11.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null - ); - } -- return (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-agent-sub-row", -- "aria-current": session.active ? "page" : void 0, -- style: { "--i": index }, -- onClick: () => actions.openAgentSession(session.id), -- onContextMenu: (event) => { -- event.preventDefault(); -- event.stopPropagation(); -- actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") -- ); ++ useFlowColumn(ref, shown, sessionId); ++ if (!shown) return null; ++ if (room2) { ++ const { rows, rest } = splitBusy(busy); ++ const open = () => actions.showDetails(sessionId); ++ return (0, import_react11.createElement)( ++ "div", ++ { ref, className: "bt-mw-dock-stack" }, ++ rows.map((entry) => (0, import_react11.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), ++ rest.length > 0 ? (0, import_react11.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null ++ ); ++ } + return (0, import_react11.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react11.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -5457,16 +6275,27 @@ diff --git a/client.js b/client.js + onOpen: () => actions.openSession(item.room.id) + }))); } --function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -- const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { -- const row = list.byId[id]; -- return { -- id, -- // A blank Session's title is the workspace folder name; show the label instead. -- title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", -- at: row?.updatedAt ?? 0, -- running: row?.running === true, -- active: (row?.retainedBy?.mainView ?? 0) > 0 +-function useRunningHost(active) { +- const [host, setHost] = (0, import_react7.useState)(null); +- (0, import_react7.useEffect)(() => { +- if (!active) { +- setHost(null); +- return void 0; +- } +- let frame = 0; +- const find = () => { +- frame = 0; +- const node = document.querySelector("[data-chat-flow] > [data-chat-running]"); +- setHost((prev) => prev === node ? prev : node); +- }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(find); +- }); +- observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); +- find(); +- return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); +function useFlowColumn(ref, active, key) { + (0, import_react11.useLayoutEffect)(() => { + const node = ref.current; @@ -5478,28 +6307,6 @@ diff --git a/client.js b/client.js + node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; + node.style.width = `${Math.round(column.width)}px`; }; -- }).sort((a, b) => b.at - a.at))); -- const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ -- id, -- running: map.get(id)?.running === true, -- unread: map.get(id)?.completionUnread === true -- })))); -- const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); -- const sessions = JSON.parse(info).map((session) => ({ -- ...session, -- running: statuses[session.id]?.running ?? session.running, -- unread: statuses[session.id]?.unread ?? false, -- title: session.title === "" ? t("New session") : session.title -- })); -- const renaming = useUi((value) => value.renameAgent); -- const active = sessions.some((session) => session.active); -- const running = sessions.some((session) => session.running); -- const unread = sessions.some((session) => session.unread) || marked && !active; -- useMarkedUnread(agent.id, active, marked, actions); -- const preview = sessions[0]?.title ?? t("New session"); -- const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -- const open = active && wide; -- return (0, import_react8.createElement)( + place(); + const observer = new ResizeObserver(place); + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); @@ -5512,9 +6319,49 @@ diff --git a/client.js b/client.js + if (!active) return void 0; + const timer = setInterval(() => setNow(Date.now()), 3e4); + return () => clearInterval(timer); -+ }, [active]); + }, [active]); +- return host; + return now; -+} + } +-function useRowEntrances(replacing) { +- const check = (0, import_react7.useRef)(replacing); +- check.current = replacing; +- (0, import_react7.useEffect)(() => { +- const seen = /* @__PURE__ */ new WeakSet(); +- const started = Date.now(); +- let column = null; +- let frame = 0; +- const settle = (event) => { +- if (event.target !== event.currentTarget || !/^bt-row/.test(event.animationName)) return; +- delete event.currentTarget.dataset.btEnter; +- event.currentTarget.removeEventListener("animationend", settle); +- }; +- const scan = () => { +- frame = 0; +- const next = document.querySelector("[data-chat-flow]"); +- if (next !== column) { +- observer.disconnect(); +- column = next; +- if (column) observer.observe(column, { childList: true, subtree: true }); +- } +- if (!column) return; +- const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; +- let last = -1; +- items.forEach((item, index) => { +- if (seen.has(item)) last = index; +- }); +- const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); +- for (const item of fresh) seen.add(item); +- if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; +- for (const item of fresh) { +- if (items.indexOf(item) < last) continue; +- const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); +- item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; +- item.addEventListener("animationend", settle); +- } +- }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(scan); +var since = (now, at) => typeof at === "number" && now - at >= 6e4 ? ` · ${elapsedLabel(now - at)}` : ""; +var alwaysChoices = (approval, bot) => bot?.remote ? [] : [ + ["always-bot", "Always allow for this Bot"], @@ -5527,61 +6374,14 @@ diff --git a/client.js b/client.js + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); + }; + return (0, import_react11.createElement)( - "div", -- { className: "bt-agent" }, -- (0, import_react8.createElement)( -- "button", -- { -- type: "button", -- className: "bt-row", -- "aria-label": label, -- "aria-current": active ? "page" : void 0, -- "data-agent-id": agent.id, -- title: wide ? void 0 : "DSH Agent", -- onClick: () => actions.openAgent(agent.id), -- onContextMenu: (event) => { -- event.preventDefault(); -- actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -- } -- }, -- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), -- wide ? (0, import_react8.createElement)( -- "span", -- { className: "bt-row-body" }, -- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), -- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -- ) : null, -- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -- ), -- (0, import_react8.createElement)( ++ "div", + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react11.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react11.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, + approval.reason ? (0, import_react11.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + approval.detail?.code ? (0, import_react11.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react11.createElement)( - "div", -- { className: "bt-agent-sub", "data-open": open || void 0 }, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-agent-sub-in" }, -- (0, import_react8.createElement)( -- "button", -- { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { -- void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); -- } }, -- (0, import_react8.createElement)(PlusIcon), -- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -- ), -- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { -- key: session.id, -- session, -- agent, -- actions, -- index: index + 1, -- renaming: renaming === session.id -- })) -- ) ++ "div", + { className: "bt-mw-actions" }, + (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react11.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), @@ -5590,59 +6390,9 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-mw-actions" }, + (0, import_react11.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) - ) - ); - } --var GLIDE = springEasing(1e3, 63); --function useListGlide(listRef, layout, key) { -- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -- (0, import_react8.useLayoutEffect)(() => { -- const list = listRef.current; -- if (!list) return; -- const next = /* @__PURE__ */ new Map(); -- const still = reducedMotion() || tops.current.layout !== layout; -- for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -- const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -- const top = row.offsetTop; -- next.set(id, top); -- const before = tops.current.map.get(id); -- if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { -- row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -- } -- } -- tops.current = { layout, map: next }; -- }, [layout, key]); --} --function TeamSidebar(props) { -- const { wide, useRoster, useUi, useSessions, actions } = props; -- const listRef = (0, import_react8.useRef)(null); -- const roster = useRoster((value) => value); -- const markedUnread = useUi((value) => value.unread); -- props.useRegistry((value) => value); -- const updated = useSessions((list) => { -- const stamps = {}; -- for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; -- return JSON.stringify(stamps); -- }); -- const order = (0, import_react8.useMemo)(() => { -- const stamps = JSON.parse(updated); -- const entries2 = [ -- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), -- ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), -- // The Agent sorts by its most recent Session, falling back to when it was added. -- ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -- ]; -- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); -- }, [roster, updated]); -- const main = roster.byId[roster.mainBotId]; -- useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); -- useLandOnMainBot(roster, useSessions, actions); -- const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -- const typeToSearch = (event) => { -- if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; -- if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; -- event.preventDefault(); -- actions.openPalette(event.key); ++ ) ++ ); ++} +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react11.useState)(false); + if (entry.state === "ask") { @@ -5657,60 +6407,18 @@ diff --git a/client.js b/client.js + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); - }; -- return (0, import_react8.createElement)( ++ }; + return (0, import_react11.createElement)( - "div", -- { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, -- wide ? (0, import_react8.createElement)( -- "div", -- { className: "bt-side-top" }, -- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -- ) : (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -- ), -- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -- (0, import_react8.createElement)( ++ "div", + { className: "bt-mw-member" }, + (0, import_react11.createElement)( - "div", -- { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, -- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) ++ "div", + { className: "bt-mw-state bt-mw-work" }, + (0, import_react11.createElement)("span", null, (0, import_react11.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react11.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); - } --function UpdateButton({ useUpdate, actions }) { -- const update = useUpdate((value) => value); -- const [open, setOpen] = (0, import_react8.useState)(false); -- const [box, setBox] = (0, import_react8.useState)(null); -- const [error, setError] = (0, import_react8.useState)(""); -- const [dshError, setDshError] = (0, import_react8.useState)(""); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -- setOpen(false); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); ++ ) ++ ); ++} +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; @@ -5803,37 +6511,60 @@ diff --git a/client.js b/client.js + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS2); -+ }); + }); +- scan(); + load(); return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); +- cancelAnimationFrame(frame); +- observer.disconnect(); + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); }; -- }, [open]); -- const phase = update?.phase ?? "idle"; -- const dsh = update?.dsh; -- const dshPhase = dsh?.phase ?? "idle"; -- const dshRow = showsDshRow(dsh, update?.app); -- if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -- const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -- setOpen((value) => !value); -- }; -- const start = () => { -- setError(""); -- void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); -- }; -- const startDsh = () => { -- setDshError(""); -- void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); -+ }, []); + }, []); + return tasks; -+} + } +-function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { +- const roster = useRoster((value) => value); +- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); +- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); +- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); +- const bot = roster.byId[sessionId]; +- const room2 = roster.roomsById[sessionId]; +- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); +- const streaming = entries2.some((entry) => entry.text); +- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); +- const relaying = tools.some((entry) => entry.relay); +- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); +- const tool = tools.at(-1); +- const inputRef = (0, import_react7.useRef)(0); +- const inputAt = Math.max(inputRef.current, pendingInput); +- inputRef.current = inputAt; +- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; +- const [shown, setShown] = (0, import_react7.useState)(null); +- const wantKey = want?.key ?? null; +- const markState = (0, import_react7.useRef)("thinking"); +- if (want) markState.current = want.state; +- (0, import_react7.useEffect)(() => { +- if (wantKey === null || shown?.key === wantKey) return void 0; +- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); +- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; +- if (wait <= 0) { +- make(); +- return void 0; +- } +- const timer = setTimeout(make, wait); +- return () => clearTimeout(timer); +- }, [wantKey, shown]); +- const [phase, setPhase] = (0, import_react7.useState)(null); +- const endedAt = (0, import_react7.useRef)(0); +- const shift = (0, import_react7.useRef)(null); +- const leaving = (0, import_react7.useRef)(false); +- leaving.current = phase !== null && !visible; +- (0, import_react7.useEffect)(() => { +- if (visible) { +- setPhase("in"); +- return void 0; +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -5952,16 +6683,7 @@ diff --git a/client.js b/client.js + chat: roomId ?? bot.id, + ...people(roster, bot.id) + }); - }; -- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -- const message = error || (phase === "failed" ? update.error : ""); -- const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); -- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -- return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("button", { -- ref: button, ++ }; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -6018,7 +6740,39 @@ diff --git a/client.js b/client.js + const text = previewOf(sessions, part); + 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) }); -+ } + } +- setPhase((current) => current === "in" ? "out" : current); +- const timer = setTimeout(() => { +- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; +- setPhase((current) => current === "out" ? null : current); +- }, 140); +- return () => clearTimeout(timer); +- }, [visible]); +- (0, import_react7.useLayoutEffect)(() => { +- if (phase !== null) return; +- endedAt.current = Date.now(); +- setShown(null); +- const before = shift.current; +- shift.current = null; +- const scroller = document.querySelector("[data-conversation-scroll]"); +- if (before === null || !scroller || reducedMotion()) return; +- const delta = before - scroller.scrollTop; +- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); +- }, [phase]); +- (0, import_react7.useEffect)(() => { +- if (!running) actions.clearLive(sessionId); +- }, [running, sessionId]); +- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); +- const [now, setNow] = (0, import_react7.useState)(Date.now); +- (0, import_react7.useEffect)(() => { +- if (phase !== "in") return void 0; +- const timer = setInterval(() => setNow(Date.now()), 1e4); +- return () => clearInterval(timer); +- }, [phase]); +- const host = useRunningHost(running); +- if (!host || phase === null || !shown) return null; +- const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; +- return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( + } + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { @@ -6038,18 +6792,12 @@ diff --git a/client.js b/client.js + return (0, import_react12.createElement)( + "button", + { - type: "button", -- className: "bt-round bt-update", -- "data-phase": cloudPhase, ++ type: "button", + className: "bt-row bt-inbox-row", - "aria-label": label, -- title: label, ++ "aria-label": label, + title: wide ? void 0 : label, - "aria-haspopup": "dialog", - "aria-expanded": open, -- onClick: toggle -- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ "aria-haspopup": "dialog", ++ "aria-expanded": open, + onClick: () => actions.openInbox() + }, + (0, import_react12.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react12.createElement)(InboxIcon)), @@ -6072,14 +6820,23 @@ diff --git a/client.js b/client.js + const meta = [who, card.place, whenText(card, now)].filter(Boolean).join(" · "); + const answerable = card.kind === "approval" && card.approval?.answerable === true; + return (0, import_react12.createElement)( -+ "div", + "div", +- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, +- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), +- (0, import_react7.createElement)( + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react12.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react12.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react12.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react12.createElement)( -+ "span", + "span", +- { className: "bt-typing-text" }, +- (0, import_react7.createElement)( + { className: "bt-inbox-copy" }, + (0, import_react12.createElement)( -+ "span", + "span", +- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, +- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), +- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null +- ) + { className: "bt-inbox-top" }, + (0, import_react12.createElement)(Status, { status: card.status }), + (0, import_react12.createElement)("span", { className: "bt-inbox-meta", title: meta }, meta) @@ -6098,9 +6855,19 @@ diff --git a/client.js b/client.js + answerable ? (0, import_react12.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "allow") }, t("Allow once")) : null, + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react12.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) -+ ) + ) +- ), host); + ); -+} + } +- +-// src/client/dsh-offer.js +-var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +-var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; +-var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); +-function showsDshRow(dsh, app) { +- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; +- if (dsh.kind === "npm") return true; +- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); +function useNow2(ms) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -6145,47 +6912,10 @@ diff --git a/client.js b/client.js + { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, + (0, import_react12.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react12.createElement)( - "div", -- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, -- update?.available || phase !== "idle" ? (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), -- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, -- phase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: phase === "installing", -- onClick: start -- }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null, -- dshRow ? (0, import_react8.createElement)( ++ "div", + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react12.createElement)( - "div", -- { className: "bt-update-dsh" }, -- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), -- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -- (0, import_react8.createElement)( -- "div", -- { className: "bt-update-actions" }, -- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { -- type: "button", -- className: "bt-update-go", -- disabled: dshPhase === "installing", -- onClick: startDsh -- }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -- ) -- ) : null -- ), document.body) : null ++ "div", + { className: "bt-mem-head" }, + (0, import_react12.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), + (0, import_react12.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), @@ -6217,30 +6947,9 @@ diff --git a/client.js b/client.js + }))) + )) + ) -+ ) - ); - } --function ConnectPlugins({ wide, actions }) { -- const tipId = (0, import_react8.useId)(); -- const [tip, setTip] = (0, import_react8.useState)(false); -- const timer = (0, import_react8.useRef)(0); -- const button = (0, import_react8.useRef)(null); -- const [box, setBox] = (0, import_react8.useState)(null); -- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); -- if (!wide) return null; -- const show = () => { -- clearTimeout(timer.current); -- timer.current = setTimeout(() => { -- const rect = button.current?.getBoundingClientRect(); -- if (!rect) return; -- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); -- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); -- setTip(true); -- }, 250); -- }; -- const hide = () => { -- clearTimeout(timer.current); -- setTip(false); ++ ) ++ ); + } +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -6278,112 +6987,110 @@ diff --git a/client.js b/client.js +.bt-inbox-actions{flex:none;display:flex;align-items:center;gap:6px;align-self:center} +.bt-inbox-actions .bt-send{height:28px;font-size:12px} +`; -+ -+// src/client/sidebar.js + + // src/client/sidebar.js +-var import_react8 = require("react"); +var import_react13 = require("react"); -+var import_react_dom3 = require("react-dom"); -+var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); -+function useMarkedUnread(id, active, marked, actions) { + var import_react_dom3 = require("react-dom"); + var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); + function useMarkedUnread(id, active, marked, actions) { +- (0, import_react8.useEffect)(() => { + (0, import_react13.useEffect)(() => { -+ if (active && marked) actions.clearUnread(id); -+ }, [active, marked]); -+} + if (active && marked) actions.clearUnread(id); + }, [active, marked]); + } +-var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); +var PinMark = () => (0, import_react13.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react13.createElement)(PinMarkIcon)); -+var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; -+var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; -+var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { -+ const [id, tool] = key.split("|"); -+ return { id, tool: tool || void 0 }; -+}); + var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; + var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; + var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { + const [id, tool] = key.split("|"); + return { id, tool: tool || void 0 }; + }); +-var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +-function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { +var workLine = (lead, tool, more) => (0, import_react13.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { -+ const id = bot.id; -+ const part = currentPart(bot); + 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 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; -+ useMarkedUnread(id, active, marked, actions); -+ const main = isMainOf(roster, id); + 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; + useMarkedUnread(id, active, marked, actions); + const main = isMainOf(roster, id); +- const tile = roster.mainBotId === id; + const tile = roster.mainBotId === id && !asRow; -+ const approval = useApproval(useWork, (entry) => entry.botId === id); + const approval = useApproval(useWork, (entry) => entry.botId === id); +- const alert = roster.questions?.[part] !== void 0 || approval; + const waiting = approval || (bot.pendingCount ?? 0) > 0; + const alert = roster.questions?.[part] !== void 0 || waiting; -+ const doing = useActivity((map) => map[id]); -+ const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); -+ const away = !running && groups.length > 0; -+ const state = botState(running || away, doing, alert); + const doing = useActivity((map) => map[id]); + const groups = workKeys(useGroupWork(useWork, (rooms) => Object.entries(rooms).flatMap(([roomId, entries2]) => entries2.filter((entry) => entry.botId === id).map((entry) => `${roomId}|${entry.tool ?? ""}`)))); + const away = !running && groups.length > 0; + const state = botState(running || away, doing, alert); +- const awayLine = away ? workLine(roster.roomsById[groups[0].id]?.name ?? t("a group chat"), groups[0].tool, groups.length - 1) : null; +- if (bot.hidden && !active && !main) return null; + const shownAway = away ? awayGroups(groups, roster.roomsById) : []; + const awayLine = shownAway.length > 0 ? workLine(roster.roomsById[shownAway[0].id]?.name ?? t("a group chat"), shownAway[0].tool, shownAway.length - 1) : null; + if (!isListedRemoteChat(bot) && !active && !main) return null; -+ const onContextMenu = (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); + const onContextMenu = (event) => { + event.preventDefault(); + actions.openMenu({ kind: "bot", id, x: event.clientX, y: event.clientY }); }; -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-connect-wrap" }, -- (0, import_react8.createElement)( -+ const pinned = bot.pinned === true && !tile; + const pinned = bot.pinned === true && !tile; +- const label = [bot.name, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", approval ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + const remote = Boolean(bot.remote); + const shown = remote ? friendBotTitle(bot) : bot.name; + const secondLine = awayLine || preview || (remote ? t("Friend's Bot") : ""); + const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -+ if (tile && wide) { + if (tile && wide) { +- return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( + return (0, import_react13.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react13.createElement)( "button", { -- ref: button, type: "button", -- className: "bt-connect bt-connect-foot", -- "aria-describedby": tip ? tipId : void 0, -- onMouseEnter: show, -- onMouseLeave: hide, -- onFocus: show, -- onBlur: hide, -- onClick: () => actions.selectPanel("plugins") -+ className: "bt-tile", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ "data-bot-id": id, -+ title: preview || bot.name, -+ onClick: () => actions.openSession(id), -+ onContextMenu +@@ -4785,55 +4785,130 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio + onClick: () => actions.openSession(id), + onContextMenu }, -- (0, import_react8.createElement)(PlugIcon), -- t("Connect plugins"), -- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") -- ), -- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), +- (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), +- bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null + (0, import_react13.createElement)(BotAvatar, { bot, size: 60, state, main: true }), + (0, import_react13.createElement)("span", { className: "bt-tile-name" }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. + unread && !waiting && !active ? (0, import_react13.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, + bot.role ? (0, import_react13.createElement)("span", { className: "bt-chip" }, bot.role) : null -+ )); -+ } + )); + } +- return (0, import_react8.createElement)( + const unreadMark = wide && waiting && !active ? (0, import_react13.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react13.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; + const row = (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, + "button", + { + type: "button", + className: "bt-row", + "aria-label": label, + "aria-current": active ? "page" : void 0, + // An own Bot's hover text names its model (#366); a friend's Bot runs on its owner's model. -+ "data-bot-id": id, + "data-bot-id": id, +- title: wide ? void 0 : bot.name, + title: remote ? shown : botRowTitle(shown, modelOf(bot, roster)?.name, wide), -+ onClick: () => actions.openSession(id), -+ onContextMenu -+ }, + onClick: () => actions.openSession(id), + onContextMenu + }, +- (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( + (0, import_react13.createElement)(BotAvatar, { bot, size: 36, state, main }), + pinned && !wide ? (0, import_react13.createElement)(PinMark) : null, + wide ? (0, import_react13.createElement)( "span", -- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, -- t("Plugin compatibility is not fully verified.") -- ), document.body) : null -+ { className: "bt-row-body" }, + { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null + (0, import_react13.createElement)( + "span", + { className: "bt-row-name" }, @@ -6392,25 +7099,10 @@ diff --git a/client.js b/client.js + pinned ? (0, import_react13.createElement)(PinMark) : null + ), + secondLine ? (0, import_react13.createElement)("span", { className: "bt-row-preview" }, secondLine) : null -+ ) : null, + ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + unreadMark - ); --} --function YouAvatar() { -- return (0, import_react8.createElement)( -- "span", -- { className: "bt-you-wrap" }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) -- ); --} --var openShellSettings = null; --var openShellOnboarding = null; --var shellShortcuts = null; --function holdShellShortcuts(shortcuts) { -- shellShortcuts = shortcuts; -- return () => { -- if (shellShortcuts === shortcuts) shellShortcuts = null; ++ ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); + const settings = wide ? (0, import_react13.createElement)("button", { + type: "button", @@ -6432,20 +7124,10 @@ diff --git a/client.js b/client.js + for (let at = node?.parentElement; at; at = at.parentElement) if (at.scrollLeft > 0) at.scrollLeft = 0; + const page = typeof document === "undefined" ? null : document.scrollingElement; + if (page && page.scrollLeft > 0) page.scrollLeft = 0; - }; ++ }; + reset(); + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); - } --function openHarnessSettings() { -- requestAnimationFrame(() => { -- if (openShellSettings) { -- openShellSettings(); -- return; -- } -- const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); -- if (desktop) openThroughMenu(desktop); -- else pressSettingsShortcut(); -- }); ++} +function RemoteMark({ remote }) { + return (0, import_react13.createElement)( + "span", @@ -6457,31 +7139,14 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ) + ); - } --function openHarnessOnboarding(id) { -- requestAnimationFrame(() => { -- if (openShellOnboarding) openShellOnboarding(id); -- else openHarnessSettings(); -- }); ++} +function isPastFaceRoom(room2) { + if (room2?.origin !== "face-to-face") return false; + const status = room2.face?.status; + if (status === "closed" || status === "left") return true; + const expires = room2.face?.expiresAt; + return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); - } --function openThroughMenu(trigger) { -- const before = new Set(document.querySelectorAll('[role="menu"]')); -- trigger.click(); -- let frames = 0; -- const look = () => { -- const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -- const item = menu?.querySelector('button[role="menuitem"]'); -- if (item) item.click(); -- else if (++frames < 30) requestAnimationFrame(look); -- else pressSettingsShortcut(); -- }; -- requestAnimationFrame(look); ++} +function FaceMark() { + return (0, import_react13.createElement)( + "span", @@ -6493,36 +7158,26 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react13.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) -+ ); + ); } --function pressSettingsShortcut() { -- const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -- if (!binding) return; -- const held = new Set(binding.modifiers); -- window.dispatchEvent(new KeyboardEvent("keydown", { -- code: binding.code, -- key: binding.code === "Comma" ? "," : "", -- bubbles: true, -- cancelable: true, -- ctrlKey: held.has("control"), -- altKey: held.has("alt"), -- shiftKey: held.has("shift"), -- metaKey: held.has("meta") -- })); -+function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { -+ const id = room2.id; + function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) { + 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 preview = nameMentions(room2.preview?.replace(/\s+/g, " ").trim(), roster) || fallback; -+ const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); -+ const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; -+ useMarkedUnread(id, active, marked, actions); -+ const approval = useApproval(useWork, (entry) => entry.roomId === id); + const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0); + const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active; + useMarkedUnread(id, active, marked, actions); + const approval = useApproval(useWork, (entry) => entry.roomId === id); + const question = roster.questions?.[id] !== void 0; -+ const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); -+ const first = roster.byId[members[0]?.id]; -+ const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; -+ if (room2.hidden && !active) return null; -+ const pinned = room2.pinned === true; + const members = workKeys(useGroupWork(useWork, (rooms) => (rooms[id] ?? []).map((entry) => `${entry.botId}|${entry.tool ?? ""}`))); + const first = roster.byId[members[0]?.id]; + const workingLine = first ? workLine(first.name, members[0].tool, members.length - 1) : null; + if (room2.hidden && !active) return null; + const pinned = room2.pinned === true; +- const label = [room2.name, pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", approval ? t("Waiting for your approval") : ""].filter(Boolean).join(", "); +- return (0, import_react8.createElement)( + const title = roomTitle(room2); + const from = room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : ""; + const hover = from && from !== title ? `${title} · ${from}` : title; @@ -6531,268 +7186,149 @@ diff --git a/client.js b/client.js + const face = room2.origin === "face-to-face"; + const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); + return (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, + "button", + { + type: "button", + className: "bt-row", + "aria-label": label, + "aria-current": active ? "page" : void 0, + // The full title on hover: a long face-room title is cut to one line in the row (#304, #173). + title: hover, -+ "data-room-id": id, -+ onClick: () => actions.openSession(id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); -+ } -+ }, + "data-room-id": id, + onClick: () => actions.openSession(id), + onContextMenu: (event) => { +@@ -4841,20 +4841,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, + actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); + } + }, +- (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), +- pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, +- wide ? (0, import_react8.createElement)( + (0, import_react13.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), + pinned && !wide ? (0, import_react13.createElement)(PinMark) : null, + wide ? (0, import_react13.createElement)( -+ "span", -+ { className: "bt-row-body" }, + "span", + { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), +- workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null + (0, import_react13.createElement)("span", { className: "bt-row-name" }, (0, import_react13.createElement)("span", { className: "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react13.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react13.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react13.createElement)(PinMark) : null), + workingLine ? (0, import_react13.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react13.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ ) : null, + ) : null, +- wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + wide && alert && !active ? (0, import_react13.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react13.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -+ ); + ); } --function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { -- const [open, setOpen] = (0, import_react8.useState)(false); + function AgentSessionRow({ session, agent, actions, renaming, index }) { +- const input = (0, import_react8.useRef)(null); - (0, import_react8.useLayoutEffect)(() => { -- if (!seat) return void 0; -- actions.seatAccount(true); -- return () => actions.seatAccount(false); -- }, [seat]); -- const [box, setBox] = (0, import_react8.useState)(null); -- const button = (0, import_react8.useRef)(null); -- (0, import_react8.useEffect)(() => { -- if (settingsOpen) setOpen(false); -- }, [settingsOpen]); -- (0, import_react8.useEffect)(() => { -- if (!openSettings) return void 0; -- openShellSettings = openSettings; -- return () => { -- if (openShellSettings === openSettings) openShellSettings = null; -- }; -- }, [openSettings]); -- (0, import_react8.useEffect)(() => { -- if (!openOnboarding) return void 0; -- openShellOnboarding = openOnboarding; -- return () => { -- if (openShellOnboarding === openOnboarding) openShellOnboarding = null; -- }; -- }, [openOnboarding]); -- (0, import_react8.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -- setOpen(false); -+function AgentSessionRow({ session, agent, actions, renaming, index }) { + const input = (0, import_react13.useRef)(null); + (0, import_react13.useLayoutEffect)(() => { -+ if (!renaming) return; -+ input.current?.focus(); -+ input.current?.select(); -+ }, [renaming]); -+ if (renaming) { -+ const commit = (keep) => { -+ const value = input.current?.value ?? ""; -+ if (!keep || value.trim() === "" || value.trim() === session.title) { -+ actions.endAgentRename(); -+ return; -+ } -+ void (async () => { -+ try { -+ await actions.renameAgentSession(agent.id, session.id, value); -+ actions.endAgentRename(); -+ } catch (error) { -+ actions.reportError(error, "agent-rename"); -+ } -+ })(); + if (!renaming) return; + input.current?.focus(); + input.current?.select(); +@@ -4875,10 +4875,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { + } + })(); }; -- const onKey = (event) => { -- if (event.key === "Escape") { +- return (0, import_react8.createElement)( + return (0, import_react13.createElement)( -+ "div", -+ { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, + "div", + { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, +- (0, import_react8.createElement)("input", { + (0, import_react13.createElement)("input", { -+ ref: input, -+ defaultValue: session.title, -+ "aria-label": t("Rename"), -+ onKeyDown: (event) => { -+ if (event.nativeEvent.isComposing) return; -+ if (event.key === "Enter") commit(true); -+ else if (event.key === "Escape") commit(false); -+ }, -+ onBlur: () => commit(true) -+ }) -+ ); -+ } + ref: input, + defaultValue: session.title, + "aria-label": t("Rename"), +@@ -4891,7 +4891,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { + }) + ); + } +- return (0, import_react8.createElement)( + return (0, import_react13.createElement)( -+ "button", -+ { -+ type: "button", -+ className: "bt-agent-sub-row", -+ "aria-current": session.active ? "page" : void 0, -+ style: { "--i": index }, -+ onClick: () => actions.openAgentSession(session.id), -+ onContextMenu: (event) => { -+ event.preventDefault(); - event.stopPropagation(); -- setOpen(false); -- button.current?.focus(); -+ actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); + "button", + { + type: "button", +@@ -4905,8 +4905,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { + actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } -+ }, + }, +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + (0, import_react13.createElement)("span", { className: "bt-agent-sub-title" }, session.title), + (0, import_react13.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") -+ ); -+} -+function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { -+ const info = useSessions((list) => JSON.stringify(agent.sessions.map((id) => { -+ const row = list.byId[id]; -+ return { -+ id, -+ // A blank Session's title is the workspace folder name; show the label instead. -+ title: row?.blank === true ? "" : row?.displayTitle ?? row?.title ?? "", -+ at: row?.updatedAt ?? 0, -+ running: row?.running === true, -+ active: (row?.retainedBy?.mainView ?? 0) > 0 - }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); -- }; -- }, [open]); -- const toggle = () => { -- const rect = button.current?.getBoundingClientRect(); -- if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); -- setOpen((value) => !value); -- }; -- const groups = [ -- [ -- [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], -- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], -- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], -- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] -- ], -- [ -- [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] -- ] -- ]; + ); + } + function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { +@@ -4941,10 +4941,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, + const preview = sessions[0]?.title ?? t("New session"); + const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); + const open = active && wide; - return (0, import_react8.createElement)( -- import_react8.Fragment, -- null, -- (0, import_react8.createElement)( -+ }).sort((a, b) => b.at - a.at))); -+ const statusInfo = useSessionStatus((map) => JSON.stringify(agent.sessions.map((id) => ({ -+ id, -+ running: map.get(id)?.running === true, -+ unread: map.get(id)?.completionUnread === true -+ })))); -+ const statuses = Object.fromEntries(JSON.parse(statusInfo).map((entry) => [entry.id, entry])); -+ const sessions = JSON.parse(info).map((session) => ({ -+ ...session, -+ running: statuses[session.id]?.running ?? session.running, -+ unread: statuses[session.id]?.unread ?? false, -+ title: session.title === "" ? t("New session") : session.title -+ })); -+ const renaming = useUi((value) => value.renameAgent); -+ const active = sessions.some((session) => session.active); -+ const running = sessions.some((session) => session.running); -+ const unread = sessions.some((session) => session.unread) || marked && !active; -+ useMarkedUnread(agent.id, active, marked, actions); -+ const preview = sessions[0]?.title ?? t("New session"); -+ const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); -+ const open = active && wide; + return (0, import_react13.createElement)( -+ "div", -+ { className: "bt-agent" }, + "div", + { className: "bt-agent" }, +- (0, import_react8.createElement)( + (0, import_react13.createElement)( "button", -- { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, -- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) -+ { -+ type: "button", -+ className: "bt-row", -+ "aria-label": label, -+ "aria-current": active ? "page" : void 0, -+ "data-agent-id": agent.id, -+ title: wide ? void 0 : "DSH Agent", -+ onClick: () => actions.openAgent(agent.id), -+ onContextMenu: (event) => { -+ event.preventDefault(); -+ actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); -+ } -+ }, + { + type: "button", +@@ -4959,30 +4959,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, + actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); + } + }, +- (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), +- wide ? (0, import_react8.createElement)( + (0, import_react13.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react13.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), + wide ? (0, import_react13.createElement)( -+ "span", -+ { className: "bt-row-body" }, + "span", + { className: "bt-row-body" }, +- (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), +- preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null + (0, import_react13.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), + preview ? (0, import_react13.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ ) : null, + ) : null, +- wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + wide && unread && !active ? (0, import_react13.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- (0, import_react8.createElement)( + (0, import_react13.createElement)( "div", -- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -- groups.map((group, index) => (0, import_react8.createElement)( -- import_react8.Fragment, -- { key: index }, -- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -- key: label, -- type: "button", -- role: "menuitem", -+ { className: "bt-agent-sub", "data-open": open || void 0 }, + { className: "bt-agent-sub", "data-open": open || void 0 }, +- (0, import_react8.createElement)( + (0, import_react13.createElement)( -+ "div", -+ { className: "bt-agent-sub-in" }, + "div", + { className: "bt-agent-sub-in" }, +- (0, import_react8.createElement)( + (0, import_react13.createElement)( -+ "button", -+ { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { -+ void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); -+ } }, + "button", + { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { + void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); + } }, +- (0, import_react8.createElement)(PlusIcon), +- (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) + (0, import_react13.createElement)(PlusIcon), + (0, import_react13.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -+ ), + ), +- sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { + sessions.map((session, index) => (0, import_react13.createElement)(AgentSessionRow, { -+ key: session.id, -+ session, -+ agent, -+ actions, -+ index: index + 1, -+ renaming: renaming === session.id -+ })) -+ ) -+ ) -+ ); -+} -+var GLIDE = springEasing(1e3, 63); -+function useListGlide(listRef, layout, key) { + key: session.id, + session, + agent, +@@ -4996,8 +4996,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, + } + var GLIDE = springEasing(1e3, 63); + function useListGlide(listRef, layout, key) { +- const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); +- (0, import_react8.useLayoutEffect)(() => { + const tops = (0, import_react13.useRef)({ layout, map: /* @__PURE__ */ new Map() }); + (0, import_react13.useLayoutEffect)(() => { -+ const list = listRef.current; -+ if (!list) return; -+ const next = /* @__PURE__ */ new Map(); -+ const still = reducedMotion() || tops.current.layout !== layout; -+ for (const row of list.querySelectorAll("[data-bot-id],[data-room-id],[data-agent-id]")) { -+ const id = row.dataset.botId ?? row.dataset.roomId ?? row.dataset.agentId; -+ const top = row.offsetTop; -+ next.set(id, top); -+ const before = tops.current.map.get(id); -+ if (!still && before !== void 0 && Math.abs(before - top) > 0.5) { -+ row.animate([{ transform: `translateY(${before - top}px)` }, { transform: "none" }], GLIDE); -+ } -+ } -+ tops.current = { layout, map: next }; -+ }, [layout, key]); -+} + const list = listRef.current; + if (!list) return; + const next = /* @__PURE__ */ new Map(); +@@ -5014,9 +5014,44 @@ function useListGlide(listRef, layout, key) { + tops.current = { layout, map: next }; + }, [layout, key]); + } +var NodeActions = class extends import_react13.Component { + constructor(props) { + super(props); @@ -6828,181 +7364,236 @@ diff --git a/client.js b/client.js + closed ? null : children.map((child) => (0, import_react13.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) + ); +} -+function TeamSidebar(props) { -+ const { wide, useRoster, useUi, useSessions, actions } = props; + function TeamSidebar(props) { + const { wide, useRoster, useUi, useSessions, actions } = props; +- const listRef = (0, import_react8.useRef)(null); + const listRef = (0, import_react13.useRef)(null); -+ const roster = useRoster((value) => value); -+ const markedUnread = useUi((value) => value.unread); -+ props.useRegistry((value) => value); -+ const updated = useSessions((list) => { -+ const stamps = {}; -+ for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; -+ return JSON.stringify(stamps); -+ }); + const roster = useRoster((value) => value); + const markedUnread = useUi((value) => value.unread); + props.useRegistry((value) => value); +@@ -5025,17 +5025,26 @@ function TeamSidebar(props) { + for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; + return JSON.stringify(stamps); + }); +- const order = (0, import_react8.useMemo)(() => { + const order = (0, import_react13.useMemo)(() => { -+ const stamps = JSON.parse(updated); -+ const entries2 = [ + const stamps = JSON.parse(updated); + const entries2 = [ +- ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), +- ...roster.rooms.map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), + // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat + // still uses BotRow's globe; gone / hidden / home-level remotes stay out. + ...roster.bots.filter((bot) => bot.id !== roster.mainBotId && isListedRemoteChat(bot)).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), + ...roster.rooms.filter((room2) => !isPastFaceRoom(room2)).map((room2) => ({ kind: "room", item: room2, at: stamps[room2.id] ?? room2.createdAt })), -+ // The Agent sorts by its most recent Session, falling back to when it was added. -+ ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) -+ ]; + // The Agent sorts by its most recent Session, falling back to when it was added. + ...(roster.agents ?? []).map((agent) => ({ kind: "agent", item: agent, at: Math.max(0, ...agent.sessions.map((id) => stamps[id] ?? 0)) || agent.createdAt })) + ]; +- return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || b.at - a.at); + const foreign = (entry) => entry.kind === "bot" && entry.item.remote || entry.kind === "room" && (entry.item.origin === "face-to-face" || entry.item.sharedFrom); + return entries2.sort((a, b) => Number(Boolean(b.item.pinned)) - Number(Boolean(a.item.pinned)) || Number(foreign(a)) - Number(foreign(b)) || b.at - a.at); -+ }, [roster, updated]); -+ const main = roster.byId[roster.mainBotId]; + }, [roster, updated]); + const main = roster.byId[roster.mainBotId]; + const structure = useSidebarStructure(); + const [collapsed, setCollapsed] = (0, import_react13.useState)({}); + const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); + const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; + const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; + const tree = structure && wide && main ? botTree([main, ...order.filter(listed).map((entry) => entry.item)], main.id, (id) => structure.parentOf(id)) : null; -+ useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); -+ useLandOnMainBot(roster, useSessions, actions); -+ const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; -+ const typeToSearch = (event) => { -+ if (event.defaultPrevented || event.key.length !== 1 || event.key === " " || event.metaKey || event.ctrlKey || event.altKey || event.nativeEvent.isComposing) return; -+ if (event.target instanceof Element && event.target.closest("input,textarea,[contenteditable]")) return; -+ event.preventDefault(); -+ actions.openPalette(event.key); -+ }; + useListGlide(listRef, wide, order.map((entry) => entry.item.id).join(",")); + useLandOnMainBot(roster, useSessions, actions); + const rowProps = { roster, actions, useSessions, useUi, useSessionStatus: props.useSessionStatus, useSessionRetainInfo: props.useSessionRetainInfo, useActivity: props.useActivity, useWork: props.useWork, wide }; +@@ -5045,39 +5045,41 @@ function TeamSidebar(props) { + event.preventDefault(); + actions.openPalette(event.key); + }; +- return (0, import_react8.createElement)( + return (0, import_react13.createElement)( -+ "div", -+ { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, + "div", + { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, +- wide ? (0, import_react8.createElement)( + wide ? (0, import_react13.createElement)( -+ "div", -+ { className: "bt-side-top" }, + "div", + { className: "bt-side-top" }, +- (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react8.createElement)(SearchIcon)), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) +- ) : (0, import_react8.createElement)( +- import_react8.Fragment, + (0, import_react13.createElement)("span", { className: "bt-brand" }, (0, import_react13.createElement)(BrandMark, { size: 22 }), (0, import_react13.createElement)(BrandName)), + (0, import_react13.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react13.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react13.createElement)(SearchIcon)), + (0, import_react13.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react13.createElement)(PlusIcon)) + ) : (0, import_react13.createElement)( + import_react13.Fragment, -+ null, + null, +- (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), +- (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) + (0, import_react13.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react13.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react13.createElement)(PlusIcon)) -+ ), + ), +- wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, +- (0, import_react8.createElement)( + wide && roster.readOnly ? (0, import_react13.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + (0, import_react13.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), + (0, import_react13.createElement)( -+ "div", -+ { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, + "div", + { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, +- main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), +- order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) + tree ? (0, import_react13.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react13.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react13.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react13.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), + order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react13.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react13.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react13.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + wide ? (0, import_react13.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react13.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null -+ ) -+ ); -+} -+function UpdateButton({ useUpdate, actions }) { -+ const update = useUpdate((value) => value); + ) + ); + } + function UpdateButton({ useUpdate, actions }) { + const update = useUpdate((value) => value); +- const [open, setOpen] = (0, import_react8.useState)(false); +- const [box, setBox] = (0, import_react8.useState)(null); +- const [error, setError] = (0, import_react8.useState)(""); +- const [dshError, setDshError] = (0, import_react8.useState)(""); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { + const [open, setOpen] = (0, import_react13.useState)(false); + const [box, setBox] = (0, import_react13.useState)(null); + const [error, setError] = (0, import_react13.useState)(""); + const [dshError, setDshError] = (0, import_react13.useState)(""); + const button = (0, import_react13.useRef)(null); + (0, import_react13.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; -+ setOpen(false); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ setOpen(false); -+ button.current?.focus(); -+ } -+ }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ }; -+ }, [open]); -+ const phase = update?.phase ?? "idle"; -+ const dsh = update?.dsh; -+ const dshPhase = dsh?.phase ?? "idle"; -+ const dshRow = showsDshRow(dsh, update?.app); -+ if (!update?.available && !dshRow && phase === "idle" && dshPhase === "idle") return null; -+ const cloudPhase = phase === "installing" || dshPhase === "installing" ? "installing" : phase !== "idle" ? phase : dshPhase; -+ const toggle = () => { -+ const rect = button.current?.getBoundingClientRect(); -+ if (rect) setBox({ left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), top: rect.bottom + 6 }); -+ setOpen((value) => !value); -+ }; -+ const start = () => { -+ setError(""); -+ void actions.installUpdate().catch((failure) => setError(hostText(failure?.message ?? String(failure)))); -+ }; -+ const startDsh = () => { -+ setDshError(""); -+ void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); -+ }; + if (!open) return void 0; + const onDown = (event) => { + if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; +@@ -5116,14 +5116,14 @@ function UpdateButton({ useUpdate, actions }) { + setDshError(""); + void actions.installDshUpdate().catch((failure) => setDshError(hostText(failure?.message ?? String(failure)))); + }; +- const label = phase === "installing" ? t("Updating DS Bot…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("DS Bot {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("DS Bot {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); + const label = phase === "installing" ? t("Updating FlyBot core…") : dshPhase === "installing" ? t("Updating DSH…") : phase === "installed" ? t("FlyBot core {version} is installed. Restart DSH to use it.", { version: update.installed ?? update.latest }) : dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : update?.available ? t("FlyBot core {version} is available", { version: update.latest }) : t("DSH {version} is available", { version: dsh?.verified }); -+ const message = error || (phase === "failed" ? update.error : ""); -+ const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); + const message = error || (phase === "failed" ? update.error : ""); + const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); +- const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update DS Bot {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); +- return (0, import_react8.createElement)( +- import_react8.Fragment, + const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); + return (0, import_react13.createElement)( + import_react13.Fragment, -+ null, + null, +- (0, import_react8.createElement)("button", { + (0, import_react13.createElement)("button", { -+ ref: button, -+ type: "button", -+ className: "bt-round bt-update", -+ "data-phase": cloudPhase, -+ "aria-label": label, -+ title: label, -+ "aria-haspopup": "dialog", -+ "aria-expanded": open, -+ onClick: toggle + ref: button, + type: "button", + className: "bt-round bt-update", +@@ -5133,21 +5133,21 @@ function UpdateButton({ useUpdate, actions }) { + "aria-haspopup": "dialog", + "aria-expanded": open, + onClick: toggle +- }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + }, (0, import_react13.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react13.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), + open && box ? (0, import_react_dom3.createPortal)((0, import_react13.createElement)( -+ "div", + "div", +- { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, +- update?.available || phase !== "idle" ? (0, import_react8.createElement)( +- import_react8.Fragment, + { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, + update?.available || phase !== "idle" ? (0, import_react13.createElement)( + import_react13.Fragment, -+ null, + null, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), +- message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, +- (0, import_react8.createElement)( + (0, import_react13.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), + (0, import_react13.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), + message ? (0, import_react13.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, + (0, import_react13.createElement)( -+ "div", -+ { className: "bt-update-actions" }, + "div", + { className: "bt-update-actions" }, +- update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, +- phase === "installed" ? null : (0, import_react8.createElement)("button", { + update.notesUrl ? (0, import_react13.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, + phase === "installed" ? null : (0, import_react13.createElement)("button", { -+ type: "button", -+ className: "bt-update-go", -+ disabled: phase === "installing", -+ onClick: start -+ }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) -+ ) -+ ) : null, + type: "button", + className: "bt-update-go", + disabled: phase === "installing", +@@ -5155,17 +5155,17 @@ function UpdateButton({ useUpdate, actions }) { + }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) + ) + ) : null, +- dshRow ? (0, import_react8.createElement)( + dshRow ? (0, import_react13.createElement)( -+ "div", -+ { className: "bt-update-dsh" }, + "div", + { className: "bt-update-dsh" }, +- (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), +- (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), +- dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, +- (0, import_react8.createElement)( + (0, import_react13.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), + (0, import_react13.createElement)("div", { className: "bt-update-text" }, dshText), + dshMessage ? (0, import_react13.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, + (0, import_react13.createElement)( -+ "div", -+ { className: "bt-update-actions" }, + "div", + { className: "bt-update-actions" }, +- dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { +- }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { + dsh.kind === "app" ? dsh.available ? (0, import_react13.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { + }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react13.createElement)("button", { -+ type: "button", -+ className: "bt-update-go", -+ disabled: dshPhase === "installing", -+ onClick: startDsh -+ }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")) -+ ) -+ ) : null -+ ), document.body) : null -+ ); -+} + type: "button", + className: "bt-update-go", + disabled: dshPhase === "installing", +@@ -5176,61 +5176,25 @@ function UpdateButton({ useUpdate, actions }) { + ), document.body) : null + ); + } +-function ConnectPlugins({ wide, actions }) { +- const tipId = (0, import_react8.useId)(); +- const [tip, setTip] = (0, import_react8.useState)(false); +- const timer = (0, import_react8.useRef)(0); +- const button = (0, import_react8.useRef)(null); +- const [box, setBox] = (0, import_react8.useState)(null); +- (0, import_react8.useEffect)(() => () => clearTimeout(timer.current), []); +function ConnectApps({ wide, actions }) { -+ if (!wide) return null; + if (!wide) return null; +- const show = () => { +- clearTimeout(timer.current); +- timer.current = setTimeout(() => { +- const rect = button.current?.getBoundingClientRect(); +- if (!rect) return; +- const left = Math.max(8, Math.min(rect.left, window.innerWidth - 268)); +- setBox({ left, bottom: window.innerHeight - rect.top + 10 }); +- setTip(true); +- }, 250); +- }; +- const hide = () => { +- clearTimeout(timer.current); +- setTip(false); +- }; +- return (0, import_react8.createElement)( +- "span", +- { className: "bt-connect-wrap" }, +- (0, import_react8.createElement)( +- "button", +- { +- ref: button, +- type: "button", +- className: "bt-connect bt-connect-foot", +- "aria-describedby": tip ? tipId : void 0, +- onMouseEnter: show, +- onMouseLeave: hide, +- onFocus: show, +- onBlur: hide, +- onClick: () => actions.selectPanel("plugins") +- }, +- (0, import_react8.createElement)(PlugIcon), +- t("Connect plugins"), +- (0, import_react8.createElement)("span", { className: "bt-connect-warn", "aria-hidden": true }, "!") +- ), +- tip && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( +- "span", +- { role: "tooltip", id: tipId, className: "bt-connect-tip", style: box }, +- t("Plugin compatibility is not fully verified.") +- ), document.body) : null + return (0, import_react13.createElement)( + "span", + { className: "bt-connect-wrap" }, @@ -7012,42 +7603,24 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(PlugIcon), + t("Connect apps") + ) -+ ); -+} -+function YouAvatar() { + ); + } + function YouAvatar() { +- return (0, import_react8.createElement)( + return (0, import_react13.createElement)( -+ "span", -+ { className: "bt-you-wrap" }, + "span", + { className: "bt-you-wrap" }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), +- (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) + (0, import_react13.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react13.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), + (0, import_react13.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) -+ ); -+} -+var openShellSettings = null; -+var openShellOnboarding = null; -+var shellShortcuts = null; -+function holdShellShortcuts(shortcuts) { -+ shellShortcuts = shortcuts; -+ return () => { -+ if (shellShortcuts === shortcuts) shellShortcuts = null; -+ }; -+} -+function openHarnessSettings() { -+ requestAnimationFrame(() => { -+ if (openShellSettings) { -+ openShellSettings(); -+ return; -+ } -+ const desktop = document.querySelector('button[aria-haspopup="menu"][data-signed-out]'); -+ if (desktop) openThroughMenu(desktop); -+ else pressSettingsShortcut(); -+ }); -+} -+function openHarnessOnboarding(id) { -+ requestAnimationFrame(() => { -+ if (openShellOnboarding) openShellOnboarding(id); -+ else openHarnessSettings(); -+ }); -+} + ); + } + var openShellSettings = null; +@@ -5259,6 +5259,18 @@ function openHarnessOnboarding(id) { + else openHarnessSettings(); + }); + } +var MODELS_STEP_ID = "flybot-models"; +var MODELS_STEP_ORDER = 1e3; +function ModelsSettingsStep({ explicit, complete, openSection }) { @@ -7060,118 +7633,96 @@ diff --git a/client.js b/client.js +function openHarnessModels() { + openHarnessOnboarding(MODELS_STEP_ID); +} -+function openThroughMenu(trigger) { -+ const before = new Set(document.querySelectorAll('[role="menu"]')); -+ trigger.click(); -+ let frames = 0; -+ const look = () => { -+ const menu = [...document.querySelectorAll('[role="menu"]')].find((node) => !before.has(node)); -+ const item = menu?.querySelector('button[role="menuitem"]'); -+ if (item) item.click(); -+ else if (++frames < 30) requestAnimationFrame(look); -+ else pressSettingsShortcut(); -+ }; -+ requestAnimationFrame(look); -+} -+function pressSettingsShortcut() { -+ const binding = shellShortcuts?.catalog.getSnapshot().find((row) => row.id === "settings.open")?.binding; -+ if (!binding) return; -+ const held = new Set(binding.modifiers); -+ window.dispatchEvent(new KeyboardEvent("keydown", { -+ code: binding.code, -+ key: binding.code === "Comma" ? "," : "", -+ bubbles: true, -+ cancelable: true, -+ ctrlKey: held.has("control"), -+ altKey: held.has("alt"), -+ shiftKey: held.has("shift"), -+ metaKey: held.has("meta") -+ })); -+} -+function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { + function openThroughMenu(trigger) { + const before = new Set(document.querySelectorAll('[role="menu"]')); + trigger.click(); +@@ -5288,32 +5288,32 @@ function pressSettingsShortcut() { + })); + } + function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { +- const [open, setOpen] = (0, import_react8.useState)(false); +- (0, import_react8.useLayoutEffect)(() => { + const [open, setOpen] = (0, import_react13.useState)(false); + (0, import_react13.useLayoutEffect)(() => { -+ if (!seat) return void 0; -+ actions.seatAccount(true); -+ return () => actions.seatAccount(false); -+ }, [seat]); + if (!seat) return void 0; + actions.seatAccount(true); + return () => actions.seatAccount(false); + }, [seat]); +- const [box, setBox] = (0, import_react8.useState)(null); +- const button = (0, import_react8.useRef)(null); +- (0, import_react8.useEffect)(() => { + const [box, setBox] = (0, import_react13.useState)(null); + const button = (0, import_react13.useRef)(null); + (0, import_react13.useEffect)(() => { -+ if (settingsOpen) setOpen(false); -+ }, [settingsOpen]); + if (settingsOpen) setOpen(false); + }, [settingsOpen]); +- (0, import_react8.useEffect)(() => { + (0, import_react13.useEffect)(() => { -+ if (!openSettings) return void 0; -+ openShellSettings = openSettings; -+ return () => { -+ if (openShellSettings === openSettings) openShellSettings = null; -+ }; -+ }, [openSettings]); + if (!openSettings) return void 0; + openShellSettings = openSettings; + return () => { + if (openShellSettings === openSettings) openShellSettings = null; + }; + }, [openSettings]); +- (0, import_react8.useEffect)(() => { + (0, import_react13.useEffect)(() => { -+ if (!openOnboarding) return void 0; -+ openShellOnboarding = openOnboarding; -+ return () => { -+ if (openShellOnboarding === openOnboarding) openShellOnboarding = null; -+ }; -+ }, [openOnboarding]); + if (!openOnboarding) return void 0; + openShellOnboarding = openOnboarding; + return () => { + if (openShellOnboarding === openOnboarding) openShellOnboarding = null; + }; + }, [openOnboarding]); +- (0, import_react8.useEffect)(() => { + (0, import_react13.useEffect)(() => { -+ if (!open) return void 0; -+ const onDown = (event) => { -+ if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; -+ setOpen(false); -+ }; -+ const onKey = (event) => { -+ if (event.key === "Escape") { -+ event.stopPropagation(); -+ setOpen(false); -+ button.current?.focus(); -+ } -+ }; -+ window.addEventListener("mousedown", onDown); -+ window.addEventListener("keydown", onKey, true); -+ return () => { -+ window.removeEventListener("mousedown", onDown); -+ window.removeEventListener("keydown", onKey, true); -+ }; -+ }, [open]); -+ const toggle = () => { -+ const rect = button.current?.getBoundingClientRect(); -+ if (rect) setBox({ left: rect.left, bottom: window.innerHeight - rect.top + 5 }); -+ setOpen((value) => !value); -+ }; -+ const groups = [ -+ [ -+ [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], + if (!open) return void 0; + const onDown = (event) => { + if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; +@@ -5341,30 +5341,32 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi + const groups = [ + [ + [t("Usage"), GaugeIcon, () => actions.openSettings("usage")], +- [t("Connectors"), ConnectorIcon, () => actions.openConnectors()], +- [t("Automation tasks"), ClockIcon, () => actions.selectPanel("schedules")], +- [t("Bot settings"), GearIcon, () => actions.openSettings("general")] + [t("Connect apps"), ConnectorIcon, () => actions.openConnectors()], + [t("Plugins"), PlugIcon, () => actions.selectPanel("plugins")], + [t("Scheduled tasks"), ClockIcon, () => actions.openSettings("schedules")], + [t("Settings"), GearIcon, () => actions.openSettings("general")] -+ ], -+ [ -+ [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] -+ ] -+ ]; + ], + [ + [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] + ] + ]; +- return (0, import_react8.createElement)( +- import_react8.Fragment, + return (0, import_react13.createElement)( + import_react13.Fragment, -+ null, + null, +- (0, import_react8.createElement)( + (0, import_react13.createElement)( -+ "button", -+ { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, + "button", + { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, +- (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) + (0, import_react13.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react13.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) -+ ), + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + open && box ? (0, import_react_dom3.createPortal)((0, import_react13.createElement)( -+ "div", -+ { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, + "div", + { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, +- groups.map((group, index) => (0, import_react8.createElement)( +- import_react8.Fragment, + (0, import_react13.createElement)(FlyBotAccountHeader, { actions }), + groups.map((group, index) => (0, import_react13.createElement)( + import_react13.Fragment, -+ { key: index }, + { key: index }, +- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, +- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { + index > 0 ? (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, + group.map(([label, Icon, run, hint]) => (0, import_react13.createElement)("button", { -+ key: label, -+ type: "button", -+ role: "menuitem", - onClick: () => { + key: label, + type: "button", + role: "menuitem", +@@ -5372,7 +5372,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -16220,7 +16771,21 @@ diff --git a/client.js b/client.js try { const ids = (value.agents ?? []).flatMap((agent) => agent.sessions ?? []); localStorage.setItem(STORAGE.agentSessions, JSON.stringify(ids)); -@@ -13501,7 +13501,7 @@ function apply(ctx) { +@@ -13488,20 +13488,12 @@ function apply(ctx) { + const style = installStyles("", "mentions"); + let last = ""; + const render = () => { +- const next = roster.getSnapshot().bots.map((bot) => { +- const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; +- const chip = /\s/.test(bot.name) ? "" : `[class*="_userRow"] [data-ref-chip="file"][title="@${bot.name.replace(/["\\]/g, "\\$&")}" i]`; +- const look = lookOf(bot); +- const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; +- const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; +- const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; +- return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; +- }).join("\n"); ++ const next = mentionCss(roster.getSnapshot().bots); + if (next !== last) style.textContent = last = next; }; render(); const offRoster = roster.subscribe(render); diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index cd1743d42..37c6a9ae4 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -630,15 +630,54 @@ diff --git a/src/client/sources.js b/src/client/sources.js // Any Main Bot holds the Main Bot powers; `mainBotId`, the first one, is the sidebar tile. export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ?? []).includes(id) -@@ -24,6 +24,8 @@ export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ? +@@ -24,9 +24,20 @@ export const isMainOf = (roster, id) => id !== undefined && (roster.mainBotIds ? // and `sessionId` its current one. Every part id finds the Bot; Session state // (running, unread, turns) is read from the current part, team state from the Bot id. export function indexRoster(value) { + setLocalBots(value.bots) + setPluginTools(value.pluginTools) const byId = {} ++ // A remote chat must not replace a local Bot's id or session (#963). Nobody ++ // takes another Bot's own id; the same Bot may refresh its own keys. ++ const claim = (bot, id) => { ++ if (id === undefined || 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]) byId[id] = bot +- for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) byId[id] = bot ++ for (const id of [...(bot.parts ?? []), bot.sessionId ?? bot.id, bot.id]) claim(bot, id) + } + const roomsById = {} + for (const room of value.rooms) roomsById[room.id] = room +@@ -40,6 +40,25 @@ export function indexRoster(value) { + return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true } + } + ++// A member id, session id, or part id. A local Bot wins when a friend's chat ++// was indexed under the same key (#963). ++export function rosterBot(roster, id) { ++ if (id === undefined || id === null || id === '') return undefined ++ 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] ++} ++ ++// The local Bot of this display name, else a friend's Bot with that name (#963). ++export function botByName(bots, name) { ++ const wanted = String(name ?? '').toLowerCase() ++ if (wanted === '') return undefined ++ 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) ++} ++ + // Whether a main-view Session id belongs to the team's DSH Agent: the conversation + // group then stays withdrawn while the shell keeps the sidebar. Before the roster + // arrives, the ids cached from the last pull answer. diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js diff --git a/patches/ds-bot/files/src/client/cluster-order.js b/patches/ds-bot/files/src/client/cluster-order.js new file mode 100644 index 000000000..60e443eb5 --- /dev/null +++ b/patches/ds-bot/files/src/client/cluster-order.js @@ -0,0 +1,12 @@ +// Paint order for a group icon. RoomAvatar draws later seats on top, so a +// friend's look must not cover a local Bot (#963). With no remote seat the +// list stays as roomAvatarPeople returned it, including [member, you]. +export function clusterOrder(people) { + const list = Array.isArray(people) ? people : [] + const remote = bot => Boolean(bot?.remote) + if (!list.some(remote)) return list + const locals = list.filter(bot => !remote(bot)) + const remotes = list.filter(remote) + if (list.length <= 4) return [...remotes, ...locals] + return [...locals, ...remotes] +} diff --git a/patches/ds-bot/files/src/client/mention-style.js b/patches/ds-bot/files/src/client/mention-style.js new file mode 100644 index 000000000..ed4cf082e --- /dev/null +++ b/patches/ds-bot/files/src/client/mention-style.js @@ -0,0 +1,37 @@ +import { lookOf, maskOf, DEFAULT_MASK } from './characters.js' +import { inkText, paintCss } from './inks.js' +import { MENTION_ORIGIN } from './text.js' + +// One @Name file chip per display name. A local Bot owns the chip when a +// friend has the same name; a later local still replaces an earlier local, +// and a later friend replaces an earlier friend only when no local has it (#963). +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null + return String(bot?.name ?? '').toLowerCase() +} + +export function mentionCss(bots) { + const list = Array.isArray(bots) ? bots : [] + const owner = new Map() + for (const bot of list) { + const key = chipKey(bot) + if (key === null) continue + const prev = owner.get(key) + if (prev && !prev.remote && bot?.remote) continue + owner.set(key, bot) + } + return list.map((bot) => { + const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]` + const key = chipKey(bot) + const chip = key !== null && owner.get(key) === bot + ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, '\\$&')}" i]` + : '' + const look = lookOf(bot) + const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before` + const mark = look.image + ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` + : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}` + const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : '' + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}` + }).join('\n') +} diff --git a/patches/ds-bot/files/test/local-look.test.js b/patches/ds-bot/files/test/local-look.test.js new file mode 100644 index 000000000..bdc10e11c --- /dev/null +++ b/patches/ds-bot/files/test/local-look.test.js @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { lookOf } from '../src/client/characters.js' +import { clusterOrder } from '../src/client/cluster-order.js' +import { mentionCss } from '../src/client/mention-style.js' +import { botByName, indexRoster, rosterBot } from '../src/client/sources.js' +import { linkMentions, MENTION_ORIGIN } from '../src/client/text.js' + +const local = { + id: 'chief', + name: 'Chief', + color: 'deepseek', + avatar: { shape: 'whale' }, + sessionId: 'chief-now', + parts: ['chief'], +} +const friend = { + id: 'remote-1', + name: 'Chief', + color: '#ff5eb1', + avatar: { shape: 'moon' }, + remote: { handle: '@chief.flybot98b8ecba.home' }, + // A friend's chat indexed under the local Bot's own id and current session. + sessionId: 'chief-now', + parts: ['chief'], +} + +test('a friend chat does not take a local Bot\'s id or session in the roster index (#963)', () => { + const roster = indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] }) + assert.equal(roster.byId.chief, local) + assert.equal(roster.byId['chief-now'], local) + assert.equal(roster.byId['remote-1'], friend) + assert.equal(lookOf(roster.byId.chief).color, 'deepseek') + assert.equal(lookOf(roster.byId.chief).shape, 'whale') + assert.equal(lookOf(roster.byId['remote-1']).color, '#ff5eb1') + // The friend's chat is listed first: the local Bot still owns its keys. + const flipped = indexRoster({ bots: [friend, local], rooms: [] }) + assert.equal(flipped.byId.chief, local) + assert.equal(flipped.byId['chief-now'], local) +}) + +test('a member id resolves to the local Bot when a friend shares that key (#963)', () => { + const roster = indexRoster({ bots: [local, friend], rooms: [] }) + assert.equal(rosterBot(roster, 'chief').color, 'deepseek') + assert.equal(rosterBot(roster, 'chief-now').avatar.shape, 'whale') + assert.equal(rosterBot(roster, 'remote-1').id, 'remote-1') + assert.equal(botByName(roster.bots, 'Chief').id, 'chief') + assert.equal(botByName([friend, local], 'Chief').id, 'chief') + assert.equal(botByName([friend], 'Chief').id, 'remote-1') +}) + +test('an @Name chip keeps the local Bot\'s look when a friend has the same name (#963)', () => { + const css = mentionCss([local, friend]) + const chip = css.split('\n').filter(line => line.includes('title="@Chief"')) + assert.ok(chip.length > 0) + assert.ok(chip.every(line => line.includes('var(--bt-ink-deepseek)') || line.includes('whale') || !line.includes('#ff5eb1'))) + assert.equal(chip.some(line => line.includes('#ff5eb1')), false) + assert.equal(chip.some(line => line.includes('var(--bt-ink-deepseek)')), true) + // The friend's own mention anchor stays the friend's color. + assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}remote-1"\\]\\{color:#ff5eb1`)) + assert.match(css, new RegExp(`a\\[href="${MENTION_ORIGIN}chief"\\]\\{color:var\\(--bt-ink-deepseek\\)`)) + // A name only a friend has still wears that friend's look. + const only = mentionCss([{ id: 'h', name: 'Helper', color: '#ff5eb1', remote: { handle: '@helper.other.home' } }]) + assert.match(only, /title="@Helper"/) + assert.match(only, /#ff5eb1/) + // Friend listed first: the local Bot still owns the shared chip. + const flipped = mentionCss([friend, local]).split('\n').filter(line => line.includes('title="@Chief"')) + assert.equal(flipped.some(line => line.includes('#ff5eb1')), false) +}) + +test('@Chief in a bubble names the local Bot when a friend is listed first (#963)', () => { + const linked = linkMentions('@Chief hello', [friend, local], 'someone-else') + assert.equal(linked, `[Chief](${MENTION_ORIGIN}chief) hello`) +}) + +test('a friend seat does not cover the local Bot in a group icon (#963)', () => { + const you = null + assert.deepEqual(clusterOrder([local, you]).map(bot => bot?.id ?? 'you'), ['chief', 'you']) + assert.deepEqual(clusterOrder([local, friend]).map(bot => bot.id), ['remote-1', 'chief']) + const many = clusterOrder([local, { id: 'b', name: 'B' }, friend, { id: 'r2', name: 'C', remote: { handle: '@c' } }, { id: 'r3', name: 'D', remote: { handle: '@d' } }]) + assert.deepEqual(many.map(bot => bot.id), ['chief', 'b', 'remote-1', 'r2', 'r3']) + const mark = readFileSync(new URL('../src/client/mark.js', import.meta.url), 'utf8') + assert.match(mark, /clusterOrder\(roomAvatarPeople/) + const client = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8') + assert.match(client, /mentionCss\(/) + const cells = readFileSync(new URL('../src/client/cells.js', import.meta.url), 'utf8') + assert.match(cells, /botByName\(roster\.bots/) + assert.match(cells, /rosterBot\(roster, reply\.botId\)/) +})