diff --git a/changelog.d/107-face-to-face-avatars.changed.md b/changelog.d/107-face-to-face-avatars.changed.md
new file mode 100644
index 000000000..ffffceaf2
--- /dev/null
+++ b/changelog.d/107-face-to-face-avatars.changed.md
@@ -0,0 +1 @@
+**Face-to-face pick and wait lists use each Bot's own avatar in a wrapping tile grid.** The "choose which of your Bots to bring" tiles and the live waiting roster use the same host `BotAvatar` as the sidebar / session list / chat header (configured image, character, or color; a friend's Bot uses its chat record or handle-seeded look, plus any avatar the member payload carries). Tiles wrap several per row (avatar on top, name centered below with ellipsis); yours is a tiny accent dot — no owner subtitle and no **mine** badge. The same grid is the Bot picker (tap a tile; a check overlay marks the ones you bring). **Leave** and **Enter chat** share one footer row while a long grid scrolls under the countdown. ([#107](https://github.com/ThinkFlowLab/FlyBot/pull/107))
diff --git a/core/client.js b/core/client.js
index 975f63898..c16404854 100644
--- a/core/client.js
+++ b/core/client.js
@@ -1413,149 +1413,6 @@ function pluginItems(structure, bot, actions) {
}]);
}
-// src/client/flybot-slots.js
-var import_react = require("react");
-var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload);
-function registry2() {
- const slots = registry();
- globalThis.__flybot__.call ??= call2;
- return slots;
-}
-var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options });
-function useSlot(name) {
- const slots = registry2();
- const [, setVersion] = (0, import_react.useState)(0);
- (0, import_react.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]);
- return slots.entries(name);
-}
-var SlotBoundary = class extends import_react.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 ? null : this.props.children;
- }
-};
-function Safe({ entry, props }) {
- return (0, import_react.createElement)(SlotBoundary, { id: entry.id }, (0, import_react.createElement)(entry.component, 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_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
- "div",
- { key: entry.id, className: "bt-flybot-section", "data-flybot": entry.id },
- (0, import_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } })
- )));
-}
-function FlyBotSessionDock({ sessionId, useRoster, actions }) {
- const entries2 = useSlot("flybot.session.dock");
- 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_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
- "div",
- { key: entry.id, className: "bt-flybot-session-dock", "data-flybot": entry.id },
- (0, import_react.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } })
- )));
-}
-function FlyBotRoomOverview({ room: room2, roster, actions }) {
- const entries2 = useSlot("flybot.room.overview");
- if (entries2.length === 0) return null;
- return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
- "div",
- { key: entry.id, className: "bt-flybot-room-overview", "data-flybot": entry.id },
- (0, import_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } })
- )));
-}
-function FlyBotDetails({ bot, roster }) {
- const entries2 = useSlot("flybot.bot.details");
- if (entries2.length === 0) return null;
- return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
- "div",
- { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id },
- (0, import_react.createElement)(Safe, { entry, props: { bot, roster, call: call2 } })
- )));
-}
-var POLL_MS = 5e3;
-function FlyBotApprovals() {
- const entries2 = useSlot("flybot.approvals");
- const [pending, setPending] = (0, import_react.useState)([]);
- const [busy, setBusy] = (0, import_react.useState)("");
- const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => {
- });
- (0, import_react.useEffect)(() => {
- void refresh();
- const timer = setInterval(refresh, POLL_MS);
- return () => clearInterval(timer);
- }, []);
- if (pending.length === 0) return null;
- const [error, setError] = (0, import_react.useState)("");
- 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();
- }
- };
- return (0, import_react.createElement)(
- "div",
- { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") },
- pending.map((approval) => {
- const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by);
- if (custom) return (0, import_react.createElement)("div", { key: approval.id }, (0, import_react.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } }));
- const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to;
- return (0, import_react.createElement)(
- "div",
- { key: approval.id, className: "bt-flybot-approval" },
- (0, import_react.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })),
- (0, import_react.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text),
- approval.request.attachments?.length ? (0, import_react.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null,
- (0, import_react.createElement)(
- "div",
- { className: "bt-flybot-approval-actions" },
- approval.status === "failed" ? (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null,
- approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")),
- approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject"))
- )
- );
- }),
- error ? (0, import_react.createElement)("div", { 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-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; }
-.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-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; }
-`;
-
// src/client/inks.js
var INKS = {
deepseek: ["#4D6BFE", "#6A84FF"],
@@ -1657,52 +1514,357 @@ for (const [id, character] of Object.entries(CHARACTERS)) {
].join("");
SHAPES[id] = { label: character.label ?? id[0].toUpperCase() + id.slice(1), size: 100, path: character.body, layers, eyes: character.eyes };
}
-var hasShape = (id) => typeof id === "string" && Object.hasOwn(SHAPES, id);
-var shapeId = (id) => hasShape(id) ? id : FALLBACK_SHAPE;
-var shapeOf = (id) => SHAPES[shapeId(id)];
-function bodyOf(def, paint, flat = false) {
- if (def.path) return `${flat ? "" : def.layers}`;
- try {
- return def.body(paint);
- } catch {
- return "";
- }
+var hasShape = (id) => typeof id === "string" && Object.hasOwn(SHAPES, id);
+var shapeId = (id) => hasShape(id) ? id : FALLBACK_SHAPE;
+var shapeOf = (id) => SHAPES[shapeId(id)];
+function bodyOf(def, paint, flat = false) {
+ if (def.path) return `${flat ? "" : def.layers}`;
+ try {
+ return def.body(paint);
+ } catch {
+ return "";
+ }
+}
+var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
+var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
+var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`;
+var holes = (def, eyes) => `${eyes}`;
+var MASK_ID = "%MASK%";
+var markSerial = 0;
+var nextMarkSerial = () => markSerial += 1;
+var markupCache = /* @__PURE__ */ new Map();
+function characterMarkup(shape, color) {
+ const key = `${shape}|${color}`;
+ let markup = markupCache.get(key);
+ if (markup === void 0) {
+ const def = shapeOf(shape);
+ const eyes = `${def.eyes.map(eyeMarkup).join("")}${def.eyes.map(happyEyeMarkup).join("")}`;
+ const stops = GRADIENTS[color];
+ const gradient = stops ? `${stops.map(([offset, value]) => ``).join("")}` : "";
+ const body = `${bodyOf(def, stops ? `url(#${MASK_ID}g)` : inkFill(color))}`;
+ markup = `${gradient}${holes(def, eyes)}${def.wrap ? `${body}` : body}`;
+ markupCache.set(key, markup);
+ }
+ return markup;
+}
+var svgUrl = (inner, size) => `url("data:image/svg+xml,${encodeURIComponent(``)}")`;
+var maskCache = /* @__PURE__ */ new Map();
+function maskOf(shape) {
+ let mask = maskCache.get(shape);
+ if (mask === void 0) {
+ const def = shapeOf(shape);
+ const body = `${bodyOf(def, "#000", true)}`;
+ mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size);
+ maskCache.set(shape, mask);
+ }
+ return mask;
+}
+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 ?? 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" : 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 }));
+ 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" })
+ );
+}
+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 RoomAvatar({ room: room2, roster, size = 36 }) {
+ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+ const people = members.length === 1 ? [members[0], null] : members;
+ const seat = (bot, seatSize, key) => bot ? (0, import_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 (people.length === 0) return (0, import_react.createElement)(BotMark, { size });
+ if (size <= 20 && people.length > 1) {
+ return (0, import_react.createElement)(
+ "span",
+ { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } },
+ people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)),
+ people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null
+ );
+ }
+ const seats = clusterSeats(Math.min(people.length, 4), size);
+ const overflow = people.length > 4 ? people.length - 3 : 0;
+ const gap = size / 14;
+ const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap }));
+ return (0, import_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 = people[index];
+ return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
+ })
+ );
+}
+
+// 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__.BotMark ??= BotMark;
+ globalThis.__flybot__.BotAvatar ??= BotAvatar;
+ globalThis.__flybot__.lookOf ??= lookOf;
+ 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 ? null : this.props.children;
+ }
+};
+function Safe({ entry, props }) {
+ return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, 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 FlyBotSessionDock({ sessionId, useRoster, actions }) {
+ const entries2 = useSlot("flybot.session.dock");
+ 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 } })
+ )));
+}
+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 eyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
-var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
-var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`;
-var holes = (def, eyes) => `${eyes}`;
-var MASK_ID = "%MASK%";
-var markSerial = 0;
-var nextMarkSerial = () => markSerial += 1;
-var markupCache = /* @__PURE__ */ new Map();
-function characterMarkup(shape, color) {
- const key = `${shape}|${color}`;
- let markup = markupCache.get(key);
- if (markup === void 0) {
- const def = shapeOf(shape);
- const eyes = `${def.eyes.map(eyeMarkup).join("")}${def.eyes.map(happyEyeMarkup).join("")}`;
- const stops = GRADIENTS[color];
- const gradient = stops ? `${stops.map(([offset, value]) => ``).join("")}` : "";
- const body = `${bodyOf(def, stops ? `url(#${MASK_ID}g)` : inkFill(color))}`;
- markup = `${gradient}${holes(def, eyes)}${def.wrap ? `${body}` : body}`;
- markupCache.set(key, markup);
- }
- return markup;
+function FlyBotDetails({ bot, roster }) {
+ const entries2 = useSlot("flybot.bot.details");
+ 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-details", "data-flybot": entry.id },
+ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } })
+ )));
}
-var svgUrl = (inner, size) => `url("data:image/svg+xml,${encodeURIComponent(``)}")`;
-var maskCache = /* @__PURE__ */ new Map();
-function maskOf(shape) {
- let mask = maskCache.get(shape);
- if (mask === void 0) {
- const def = shapeOf(shape);
- const body = `${bodyOf(def, "#000", true)}`;
- mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size);
- maskCache.set(shape, mask);
- }
- return mask;
+var POLL_MS = 5e3;
+function FlyBotApprovals() {
+ const entries2 = useSlot("flybot.approvals");
+ const [pending, setPending] = (0, import_react2.useState)([]);
+ const [busy, setBusy] = (0, import_react2.useState)("");
+ const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => {
+ });
+ (0, import_react2.useEffect)(() => {
+ void refresh();
+ const timer = setInterval(refresh, POLL_MS);
+ return () => clearInterval(timer);
+ }, []);
+ if (pending.length === 0) return null;
+ const [error, setError] = (0, import_react2.useState)("");
+ 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();
+ }
+ };
+ return (0, import_react2.createElement)(
+ "div",
+ { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") },
+ pending.map((approval) => {
+ const custom = entries2.find((entry) => 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 to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to;
+ return (0, import_react2.createElement)(
+ "div",
+ { key: approval.id, className: "bt-flybot-approval" },
+ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })),
+ (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" },
+ approval.status === "failed" ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null,
+ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")),
+ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject"))
+ )
+ );
+ }),
+ error ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null
+ );
}
-var DEFAULT_MASK = maskOf(FALLBACK_SHAPE);
+var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
+var FLYBOT_CSS = `
+.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; }
+.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-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; }
+`;
// src/client/sources.js
function createSource(initial) {
@@ -2108,7 +2270,7 @@ function usageOf(list, ids) {
}
// src/client/motion.js
-var import_react2 = require("react");
+var import_react3 = require("react");
var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
function springEasing(stiffness, damping) {
const w0 = Math.sqrt(stiffness);
@@ -2181,10 +2343,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_react2.useRef)(null);
+ const ownBox = (0, import_react3.useRef)(null);
const box = given ?? ownBox;
- const thumb = (0, import_react2.useRef)(null);
- const own = (0, import_react2.useRef)(null);
+ const thumb = (0, import_react3.useRef)(null);
+ const own = (0, import_react3.useRef)(null);
if (own.current === null) {
own.current = {
key: void 0,
@@ -2218,7 +2380,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_react2.useLayoutEffect)(() => {
+ (0, import_react3.useLayoutEffect)(() => {
const state = own.current;
const node = box.current;
if (node === state.node && key === state.key) return;
@@ -2232,7 +2394,7 @@ function useGlide(key, { axis = "x", selector = CHOSEN, lead = SPRING_LEAD, trai
}
place(fresh);
});
- (0, import_react2.useEffect)(() => () => {
+ (0, import_react3.useEffect)(() => () => {
own.current.observer?.disconnect();
own.current.springs.stop();
}, []);
@@ -2240,16 +2402,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_react2.createElement)(
+ return (0, import_react3.createElement)(
"div",
{ ref: glide.box, className: className ? `bt-seg ${className}` : "bt-seg", role: "group", "aria-label": label },
- (0, import_react2.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ (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_react2.useRef)(null);
- (0, import_react2.useLayoutEffect)(() => {
+ const tops = (0, import_react3.useRef)(null);
+ (0, import_react3.useLayoutEffect)(() => {
const container = list.current;
if (!container) return;
const next = /* @__PURE__ */ new Map();
@@ -2270,8 +2432,8 @@ function useFlip(list, key, selector, id = (node) => node.dataset.flip) {
}, [key]);
}
function useMorph(box, key) {
- const last = (0, import_react2.useRef)({ key, height: null });
- (0, import_react2.useLayoutEffect)(() => {
+ const last = (0, import_react3.useRef)({ key, height: null });
+ (0, import_react3.useLayoutEffect)(() => {
const node = box.current;
const state = last.current;
if (!node) {
@@ -2299,8 +2461,8 @@ function useMorph(box, key) {
});
}
function useLinger(value, ms) {
- const [kept, setKept] = (0, import_react2.useState)(value);
- (0, import_react2.useEffect)(() => {
+ const [kept, setKept] = (0, import_react3.useState)(value);
+ (0, import_react3.useEffect)(() => {
if (value) {
setKept(value);
return void 0;
@@ -3345,165 +3507,6 @@ function installCompiledStyles(text, tag) {
};
}
-// src/client/mark.js
-var import_react3 = 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_react3.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 ?? lookOf(bot);
- const ref = (0, import_react3.useRef)(null);
- const maskId = (0, import_react3.useRef)("");
- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`;
- const [poked, setPoked] = (0, import_react3.useState)(false);
- useGaze(ref, gaze);
- const common = {
- ref,
- className: poked ? "bt-mark bt-mark-poke" : "bt-mark",
- "data-shape": look.image ? "image" : shapeId(look.shape),
- "data-state": STATES.includes(state) ? state : "idle",
- "data-live": live ? "" : void 0,
- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0,
- "data-gaze": gaze ? "" : void 0,
- "data-poke": pokeable ? "" : void 0,
- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} },
- role: label ? "img" : void 0,
- "aria-label": label,
- "aria-hidden": label ? void 0 : true,
- onClick: pokeable ? () => setPoked(true) : void 0,
- onAnimationEnd: poked ? (event) => {
- if (/^bt-poke/.test(event.animationName)) setPoked(false);
- } : void 0
- };
- if (look.image) return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false }));
- const box = shapeOf(look.shape).size;
- return (0, import_react3.createElement)("span", common, (0, import_react3.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_react3.createElement)(
- "svg",
- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true },
- (0, import_react3.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" })
- );
-}
-function AdminStar() {
- return (0, import_react3.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react3.createElement)(StarIcon, { size: 7 }));
-}
-function MainStar() {
- return (0, import_react3.createElement)(
- "svg",
- { viewBox: "0 0 20 20", "aria-hidden": true },
- (0, import_react3.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_react3.useRef)(state);
- const [settled, setSettled] = (0, import_react3.useState)(false);
- (0, import_react3.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_react3.createElement)(BotMark, { size });
- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null;
- return (0, import_react3.createElement)(
- "span",
- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } },
- (0, import_react3.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }),
- badgeKind === "working" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null,
- badgeKind === "alert" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null,
- badgeKind === "admin" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react3.createElement)(StarIcon)) : null,
- badgeKind === "main" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react3.createElement)(MainStar)) : null
- );
-}
-function clusterSeats(count, frame) {
- if (count === 1) return [[0, 0, frame]];
- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]];
- if (count === 3) {
- const size = frame * 5 / 9;
- const rest = frame - size;
- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]];
- }
- const half = frame / 2;
- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]];
-}
-function RoomAvatar({ room: room2, roster, size = 36 }) {
- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
- const people = members.length === 1 ? [members[0], null] : members;
- const seat = (bot, seatSize, key) => bot ? (0, import_react3.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react3.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } });
- if (people.length === 0) return (0, import_react3.createElement)(BotMark, { size });
- if (size <= 20 && people.length > 1) {
- return (0, import_react3.createElement)(
- "span",
- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } },
- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)),
- people.length > 3 ? (0, import_react3.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null
- );
- }
- const seats = clusterSeats(Math.min(people.length, 4), size);
- const overflow = people.length > 4 ? people.length - 3 : 0;
- const gap = size / 14;
- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap }));
- return (0, import_react3.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_react3.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`);
- }
- const bot = people[index];
- return (0, import_react3.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
- })
- );
-}
-
// src/client/icons.js
var import_react4 = require("react");
function PlusIcon() {
diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js
index 8a7c962f0..70534b5ac 100644
--- a/core/src/client/flybot-slots.js
+++ b/core/src/client/flybot-slots.js
@@ -15,11 +15,15 @@
// flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer
// flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat)
//
+// The same registry object also carries the host Bot look so a plugin can draw a Bot
+// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()).
+//
// Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry).
import { Component, createElement as h, Fragment, useEffect, useState } from 'react'
import { call as callHost } from './api.js'
import { isListedRemoteChat } from '../shared/remote-label.js'
import { registry as baseRegistry } from './flybot-registry.js'
+import { BotAvatar, BotMark, lookOf } from './mark.js'
import { t } from './i18n.js'
import { createClientApi as createClientApiWith } from './flybot-registry.js'
@@ -32,6 +36,11 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa
export function registry() {
const slots = baseRegistry()
globalThis.__flybot__.call ??= call
+ // Classic-script plugins cannot import mark.js; they render the same Bot look
+ // the sidebar / session list / chat header use via these host components.
+ globalThis.__flybot__.BotMark ??= BotMark
+ globalThis.__flybot__.BotAvatar ??= BotAvatar
+ globalThis.__flybot__.lookOf ??= lookOf
return slots
}
diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts
index 2f85e4fc5..e951ca898 100644
--- a/core/types/flybot.d.ts
+++ b/core/types/flybot.d.ts
@@ -301,7 +301,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise
* How a client plugin gets it: a DSH web client bundle (`window.__ModuleLoader__.load({ id,
* inject: ['flybot'], ... })`, see plugins/tree/client.js) receives the browser service as
* `ctx.flybot` (a {@link FlyBotClient}). The same registry is also on `globalThis.__flybot__`
- * for code without a Cordis context. Slots below are FlyBot's own registry: register them
+ * for code without a Cordis context (`BotAvatar` / `BotMark` / `lookOf` are the host Bot
+ * look, set when the client registry() runs). Slots below are FlyBot's own registry: register them
* with `ctx.flybot.slots.register(name, entry)`, NOT the shell's `ctx.slots` (which throws
* `slot "flybot.…" is not declared`). Every `register` returns a disposer; call it from
* `ctx.effect` so a reload or uninstall removes the entry. Entries are sorted by `order`
@@ -367,9 +368,23 @@ export interface FlyBotClientRegistry {
subscribe(listener: () => void): () => void
}
+/** Host Bot look: same component the sidebar / session list / chat header render. */
+export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any
+export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any
+export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string }
+
declare global {
// eslint-disable-next-line no-var
- var __flybot__: { slots: FlyBotClientRegistry; call?: FlyBotCall; api?: FlyBotClient } | undefined
+ var __flybot__: {
+ slots: FlyBotClientRegistry
+ call?: FlyBotCall
+ api?: FlyBotClient
+ /** Same character mark as the sidebar / chat header. Set by the host client registry(). */
+ BotMark?: FlyBotBotMark
+ /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */
+ BotAvatar?: FlyBotBotAvatar
+ lookOf?: FlyBotLookOf
+ } | undefined
}
declare module 'cordis' {
diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md
index 48f0efecc..5da4b83e7 100644
--- a/docs/subsystems/core-client.md
+++ b/docs/subsystems/core-client.md
@@ -35,6 +35,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t
- `client.js` matches `core/src/client/`; `npm run build` + `--check` (also invariant 3 of [ds-bot-core](ds-bot-core.md)).
+### Host Bot look in the bundle
+
+- The committed bundle includes `flybot-slots.js` `registry()`, which exposes `BotAvatar` / `BotMark` / `lookOf` on `globalThis.__flybot__` for classic-script plugins. Behavior is specified on [plugin-runtime](plugin-runtime.md).
+
### Remote Bot display
- 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).
diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md
index d324aa809..9a395f960 100644
--- a/docs/subsystems/plugin-runtime.md
+++ b/docs/subsystems/plugin-runtime.md
@@ -52,6 +52,10 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots
8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`.
9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Slot `flybot.new-chat.action` adds a row to the sidebar **+** new-chat menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode.
+### Host Bot look for classic-script plugins
+
+- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`.
+
## Tests
`core/test/flybot.test.js`, `core/test/flybot-client.test.js` via `npm test`.
diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md
index cbd9da9ec..412876ada 100644
--- a/docs/subsystems/social.md
+++ b/docs/subsystems/social.md
@@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s
Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send.
-Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls).
+Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot, no owner subtitle); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls).
## Owns
@@ -30,7 +30,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
| `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. |
| `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, invites). |
| `plugins/social/src/face-rooms.js` | Face-to-face REST client, error mapping, event rendering, peer-string sanitization, GET/PATCH `/v1/me` auto-approve (#83). |
-| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker, 6-digit join, waiting room, Enter chat), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. |
+| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker and waiting room as a wrapping tile grid of each Bot's own avatar, 6-digit join, Enter chat + Leave footer), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. |
## Entry points
@@ -43,7 +43,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it.
4. Access is a per-Bot **grant** (#90): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted to me). Accepting **Invite to this Bot** is the grant; `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, MCP then REST) let the owner see where the Bot is in use and revoke one person without touching other grants. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`.
5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state.
-6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); the client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots as mine; **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published.
+6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published.
## Tests
diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch
index d9f0f94e8..e4c9aea37 100644
--- a/patches/ds-bot/0008-client-bundle.patch
+++ b/patches/ds-bot/0008-client-bundle.patch
@@ -112,7 +112,7 @@ diff --git a/client.js b/client.js
body: JSON.stringify({ endpoint, payload: payload ?? null }),
signal
});
-@@ -1097,6 +1097,412 @@ var call = async (endpoint, payload, signal) => {
+@@ -1097,6 +1097,269 @@ var call = async (endpoint, payload, signal) => {
return result.value;
};
@@ -379,22 +379,219 @@ diff --git a/client.js b/client.js
+ }]);
+}
+
-+// src/client/flybot-slots.js
+ // src/client/inks.js
+ var INKS = {
+ deepseek: ["#4D6BFE", "#6A84FF"],
+@@ -1177,11 +1177,14 @@ function defaultShape(id) {
+ value = Math.imul(value ^ value >>> 13, 3266489909);
+ return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length];
+ }
+-var colorOf = (bot) => isColor(bot?.color) ? bot.color : bot?.id ? defaultColor(String(bot.id)) : "black";
++var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`;
++var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0;
++var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black";
+ function lookOf(bot) {
+ const avatar = bot?.avatar ?? {};
++ const seed = lookSeed(bot);
+ return {
+- shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE,
++ shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE,
+ image: avatar.image,
+ color: colorOf(bot)
+ };
+@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) {
+ return "";
+ }
+ }
+-var eyeMarkup = ([cx, cy, w, h34, tilt]) => ``;
+-var happyEyeMarkup = ([cx, cy, w, h34, tilt]) => ``;
++var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
++var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
+ 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,6 +1242,311 @@ 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 ?? 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" : 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 }));
++ 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" })
++ );
++}
++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 RoomAvatar({ room: room2, roster, size = 36 }) {
++ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
++ const people = members.length === 1 ? [members[0], null] : members;
++ const seat = (bot, seatSize, key) => bot ? (0, import_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 (people.length === 0) return (0, import_react.createElement)(BotMark, { size });
++ if (size <= 20 && people.length > 1) {
++ return (0, import_react.createElement)(
++ "span",
++ { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } },
++ people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)),
++ people.length > 3 ? (0, import_react.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null
++ );
++ }
++ const seats = clusterSeats(Math.min(people.length, 4), size);
++ const overflow = people.length > 4 ? people.length - 3 : 0;
++ const gap = size / 14;
++ const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap }));
++ return (0, import_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 = people[index];
++ return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
++ })
++ );
++}
++
++// 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__.BotMark ??= BotMark;
++ globalThis.__flybot__.BotAvatar ??= BotAvatar;
++ globalThis.__flybot__.lookOf ??= lookOf;
+ return slots;
+}
+var createClientApi2 = (options = {}) => createClientApi({ call: call2, registry: registry2, ...options });
+function useSlot(name) {
+ const slots = registry2();
-+ const [, setVersion] = (0, import_react.useState)(0);
-+ (0, import_react.useEffect)(() => slots.subscribe(() => setVersion(slots.version())), [slots]);
++ 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_react.Component {
++var SlotBoundary = class extends import_react2.Component {
+ constructor(props) {
+ super(props);
+ this.state = { failed: false };
@@ -410,7 +607,7 @@ diff --git a/client.js b/client.js
+ }
+};
+function Safe({ entry, props }) {
-+ return (0, import_react.createElement)(SlotBoundary, { id: entry.id }, (0, import_react.createElement)(entry.component, props));
++ return (0, import_react2.createElement)(SlotBoundary, { id: entry.id }, (0, import_react2.createElement)(entry.component, props));
+}
+function useSidebarStructure() {
+ return useSlot("flybot.sidebar.structure")[0];
@@ -419,10 +616,10 @@ diff --git a/client.js b/client.js
+ 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_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
++ 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_react.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } })
++ (0, import_react2.createElement)(Safe, { entry, props: { roster, actions, call: call2, renderBot, remoteChats } })
+ )));
+}
+function FlyBotSessionDock({ sessionId, useRoster, actions }) {
@@ -431,44 +628,44 @@ diff --git a/client.js b/client.js
+ if (entries2.length === 0) return null;
+ const room2 = roster?.roomsById?.[sessionId];
+ const bot = room2 ? void 0 : roster?.byId?.[sessionId];
-+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
++ 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_react.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } })
++ (0, import_react2.createElement)(Safe, { entry, props: { sessionId, roster, room: room2, bot, actions, call: call2 } })
+ )));
+}
+function FlyBotRoomOverview({ room: room2, roster, actions }) {
+ const entries2 = useSlot("flybot.room.overview");
+ if (entries2.length === 0) return null;
-+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
++ 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_react.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } })
++ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } })
+ )));
+}
+function FlyBotDetails({ bot, roster }) {
+ const entries2 = useSlot("flybot.bot.details");
+ if (entries2.length === 0) return null;
-+ return (0, import_react.createElement)(import_react.Fragment, null, entries2.map((entry) => (0, import_react.createElement)(
++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)(
+ "div",
+ { key: entry.id, className: "bt-flybot-details", "data-flybot": entry.id },
-+ (0, import_react.createElement)(Safe, { entry, props: { bot, roster, call: call2 } })
++ (0, import_react2.createElement)(Safe, { entry, props: { bot, roster, call: call2 } })
+ )));
+}
+var POLL_MS = 5e3;
+function FlyBotApprovals() {
+ const entries2 = useSlot("flybot.approvals");
-+ const [pending, setPending] = (0, import_react.useState)([]);
-+ const [busy, setBusy] = (0, import_react.useState)("");
++ const [pending, setPending] = (0, import_react2.useState)([]);
++ const [busy, setBusy] = (0, import_react2.useState)("");
+ const refresh = () => call2("approvals", { status: ["pending", "failed"] }).then((list) => setPending(Array.isArray(list) ? list : [])).catch(() => {
+ });
-+ (0, import_react.useEffect)(() => {
++ (0, import_react2.useEffect)(() => {
+ void refresh();
+ const timer = setInterval(refresh, POLL_MS);
+ return () => clearInterval(timer);
+ }, []);
+ if (pending.length === 0) return null;
-+ const [error, setError] = (0, import_react.useState)("");
++ const [error, setError] = (0, import_react2.useState)("");
+ const decide = async (id, decision) => {
+ setBusy(id);
+ setError("");
@@ -481,29 +678,29 @@ diff --git a/client.js b/client.js
+ await refresh();
+ }
+ };
-+ return (0, import_react.createElement)(
++ return (0, import_react2.createElement)(
+ "div",
+ { className: "bt-flybot-approvals", role: "region", "aria-label": t("Messages waiting for your approval") },
+ pending.map((approval) => {
+ const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by);
-+ if (custom) return (0, import_react.createElement)("div", { key: approval.id }, (0, import_react.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } }));
++ 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 to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to;
-+ return (0, import_react.createElement)(
++ return (0, import_react2.createElement)(
+ "div",
+ { key: approval.id, className: "bt-flybot-approval" },
-+ (0, import_react.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })),
-+ (0, import_react.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text),
-+ approval.request.attachments?.length ? (0, import_react.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null,
-+ (0, import_react.createElement)(
++ (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })),
++ (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" },
-+ approval.status === "failed" ? (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null,
-+ approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")),
-+ approval.status === "failed" ? null : (0, import_react.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject"))
++ approval.status === "failed" ? (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "retry") }, t("Retry")) : null,
++ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "approve") }, t("Approve")),
++ approval.status === "failed" ? null : (0, import_react2.createElement)("button", { type: "button", disabled: busy === approval.id, onClick: () => decide(approval.id, "reject") }, t("Reject"))
+ )
+ );
+ }),
-+ error ? (0, import_react.createElement)("div", { className: "bt-flybot-approval-error", role: "alert" }, error) : null
++ error ? (0, import_react2.createElement)("div", { 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`;
@@ -522,37 +719,9 @@ diff --git a/client.js b/client.js
+.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; }
+`;
+
- // src/client/inks.js
- var INKS = {
- deepseek: ["#4D6BFE", "#6A84FF"],
-@@ -1177,11 +1177,14 @@ function defaultShape(id) {
- value = Math.imul(value ^ value >>> 13, 3266489909);
- return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length];
- }
--var colorOf = (bot) => isColor(bot?.color) ? bot.color : bot?.id ? defaultColor(String(bot.id)) : "black";
-+var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`;
-+var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0;
-+var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black";
- function lookOf(bot) {
- const avatar = bot?.avatar ?? {};
-+ const seed = lookSeed(bot);
- return {
-- shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE,
-+ shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE,
- image: avatar.image,
- color: colorOf(bot)
- };
-@@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) {
- return "";
- }
- }
--var eyeMarkup = ([cx, cy, w, h34, tilt]) => ``;
--var happyEyeMarkup = ([cx, cy, w, h34, tilt]) => ``;
-+var eyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
-+var happyEyeMarkup = ([cx, cy, w, h35, tilt]) => ``;
- var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`;
- var holes = (def, eyes) => `${eyes}`;
- var MASK_ID = "%MASK%";
+ // src/client/sources.js
+ function createSource(initial) {
+ let value = initial;
@@ -1262,7 +1262,7 @@ function createSource(initial) {
}
};
@@ -642,7 +811,7 @@ diff --git a/client.js b/client.js
// src/client/motion.js
-var import_react = require("react");
-+var import_react2 = require("react");
++var import_react3 = require("react");
var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
function springEasing(stiffness, damping) {
const w0 = Math.sqrt(stiffness);
@@ -667,12 +836,12 @@ diff --git a/client.js b/client.js
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 ownBox = (0, import_react2.useRef)(null);
++ 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 thumb = (0, import_react2.useRef)(null);
-+ const own = (0, import_react2.useRef)(null);
++ const thumb = (0, import_react3.useRef)(null);
++ const own = (0, import_react3.useRef)(null);
if (own.current === null) {
own.current = {
key: void 0,
@@ -681,7 +850,7 @@ diff --git a/client.js b/client.js
container.dataset.glide = "";
};
- (0, import_react.useLayoutEffect)(() => {
-+ (0, import_react2.useLayoutEffect)(() => {
++ (0, import_react3.useLayoutEffect)(() => {
const state = own.current;
const node = box.current;
if (node === state.node && key === state.key) return;
@@ -690,7 +859,7 @@ diff --git a/client.js b/client.js
place(fresh);
});
- (0, import_react.useEffect)(() => () => {
-+ (0, import_react2.useEffect)(() => () => {
++ (0, import_react3.useEffect)(() => () => {
own.current.observer?.disconnect();
own.current.springs.stop();
}, []);
@@ -699,19 +868,19 @@ diff --git a/client.js b/client.js
function Segmented({ label, value, className, children }) {
const glide = useGlide(value);
- return (0, import_react.createElement)(
-+ return (0, import_react2.createElement)(
++ 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 }),
-+ (0, import_react2.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ (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)(() => {
-+ const tops = (0, import_react2.useRef)(null);
-+ (0, import_react2.useLayoutEffect)(() => {
++ const tops = (0, import_react3.useRef)(null);
++ (0, import_react3.useLayoutEffect)(() => {
const container = list.current;
if (!container) return;
const next = /* @__PURE__ */ new Map();
@@ -721,8 +890,8 @@ diff --git a/client.js b/client.js
function useMorph(box, key) {
- const last = (0, import_react.useRef)({ key, height: null });
- (0, import_react.useLayoutEffect)(() => {
-+ const last = (0, import_react2.useRef)({ key, height: null });
-+ (0, import_react2.useLayoutEffect)(() => {
++ const last = (0, import_react3.useRef)({ key, height: null });
++ (0, import_react3.useLayoutEffect)(() => {
const node = box.current;
const state = last.current;
if (!node) {
@@ -732,8 +901,8 @@ diff --git a/client.js b/client.js
function useLinger(value, ms) {
- const [kept, setKept] = (0, import_react.useState)(value);
- (0, import_react.useEffect)(() => {
-+ const [kept, setKept] = (0, import_react2.useState)(value);
-+ (0, import_react2.useEffect)(() => {
++ const [kept, setKept] = (0, import_react3.useState)(value);
++ (0, import_react3.useEffect)(() => {
if (value) {
setKept(value);
return void 0;
@@ -785,140 +954,271 @@ diff --git a/client.js b/client.js
.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)}
.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap}
.bt-event>span{padding:0 2px}
-@@ -2856,12 +2856,12 @@ function installCompiledStyles(text, tag) {
- }
-
- // src/client/mark.js
+@@ -2801,280 +2801,121 @@ function sheetClassNames(sheet) {
+ if (rule.cssRules) visit(rule.cssRules);
+ }
+ };
+- try {
+- visit(sheet.cssRules);
+- } catch {
+- }
+- return names;
+-}
+-function compileFragmentSelectors(text, names) {
+- const compiled = /* @__PURE__ */ new Map();
+- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => {
+- if (!compiled.has(fragment)) {
+- const exact = [];
+- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`);
+- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole);
+- }
+- return compiled.get(fragment);
+- });
+-}
+-function installCompiledStyles(text, tag) {
+- const style = installStyles("", tag);
+- const cache = /* @__PURE__ */ new WeakMap();
+- const render = () => {
+- const names = /* @__PURE__ */ new Set();
+- for (const sheet of document.styleSheets) {
+- if (sheet.ownerNode?.dataset?.dshBot) continue;
+- let list = cache.get(sheet);
+- if (!list) {
+- list = sheetClassNames(sheet);
+- cache.set(sheet, list);
+- }
+- for (const name of list) names.add(name);
+- }
+- const next = compileFragmentSelectors(text, names);
+- if (style.textContent !== next) style.textContent = next;
+- };
+- render();
+- let timer;
+- const schedule = () => {
+- clearTimeout(timer);
+- timer = setTimeout(render, 120);
+- };
+- const onLoad = (event) => {
+- if (event.target instanceof HTMLLinkElement) schedule();
+- };
+- const observer = new MutationObserver(schedule);
+- observer.observe(document.head, { childList: true });
+- document.addEventListener("load", onLoad, true);
+- return () => {
+- observer.disconnect();
+- document.removeEventListener("load", onLoad, true);
+- clearTimeout(timer);
+- style.remove();
+- };
+-}
+-
+-// src/client/mark.js
-var import_react2 = require("react");
-+var import_react3 = 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) {
+-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"];
+-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle";
+-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working";
+-function useGaze(ref, enabled) {
- (0, import_react2.useEffect)(() => {
-+ (0, import_react3.useEffect)(() => {
- if (!enabled) return void 0;
- let frame = 0;
- let point = null;
-@@ -2895,10 +2895,10 @@ function useGaze(ref, enabled) {
- var FIDGETS = ["hop", "sway", "stretch", "look"];
- function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) {
- const look = given ?? lookOf(bot);
+- 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 ?? lookOf(bot);
- const ref = (0, import_react2.useRef)(null);
- const maskId = (0, import_react2.useRef)("");
-+ const ref = (0, import_react3.useRef)(null);
-+ const maskId = (0, import_react3.useRef)("");
- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`;
+- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`;
- const [poked, setPoked] = (0, import_react2.useState)(false);
-+ const [poked, setPoked] = (0, import_react3.useState)(false);
- useGaze(ref, gaze);
- const common = {
- ref,
-@@ -2918,31 +2918,31 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga
- if (/^bt-poke/.test(event.animationName)) setPoked(false);
- } : void 0
- };
+- useGaze(ref, gaze);
+- const common = {
+- ref,
+- className: poked ? "bt-mark bt-mark-poke" : "bt-mark",
+- "data-shape": look.image ? "image" : shapeId(look.shape),
+- "data-state": STATES.includes(state) ? state : "idle",
+- "data-live": live ? "" : void 0,
+- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0,
+- "data-gaze": gaze ? "" : void 0,
+- "data-poke": pokeable ? "" : void 0,
+- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} },
+- role: label ? "img" : void 0,
+- "aria-label": label,
+- "aria-hidden": label ? void 0 : true,
+- onClick: pokeable ? () => setPoked(true) : void 0,
+- onAnimationEnd: poked ? (event) => {
+- if (/^bt-poke/.test(event.animationName)) setPoked(false);
+- } : void 0
+- };
- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false }));
-+ if (look.image) return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false }));
- const box = shapeOf(look.shape).size;
+- 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) } }));
-+ return (0, import_react3.createElement)("span", common, (0, import_react3.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } }));
- }
- function StarIcon({ size = 8 }) {
+-}
+-function StarIcon({ size = 8 }) {
- return (0, import_react2.createElement)(
-+ return (0, import_react3.createElement)(
- "svg",
- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true },
+- "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" })
-+ (0, import_react3.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" })
- );
- }
- function AdminStar() {
+- );
+-}
+-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 }));
-+ return (0, import_react3.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react3.createElement)(StarIcon, { size: 7 }));
- }
- function MainStar() {
+-}
+-function MainStar() {
- return (0, import_react2.createElement)(
-+ return (0, import_react3.createElement)(
- "svg",
- { viewBox: "0 0 20 20", "aria-hidden": true },
+- "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" })
-+ (0, import_react3.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) {
+- );
+-}
+-function useSettled(state) {
- const prev = (0, import_react2.useRef)(state);
- const [settled, setSettled] = (0, import_react2.useState)(false);
- (0, import_react2.useEffect)(() => {
-+ const prev = (0, import_react3.useRef)(state);
-+ const [settled, setSettled] = (0, import_react3.useState)(false);
-+ (0, import_react3.useEffect)(() => {
- const was = prev.current;
- prev.current = state;
- if (state !== "idle") {
-@@ -2958,16 +2958,16 @@ function useSettled(state) {
- }
- function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) {
- const settled = useSettled(state);
+- 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_react2.createElement)(BotMark, { size });
-+ if (bot === void 0) return (0, import_react3.createElement)(BotMark, { size });
- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null;
+- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null;
- return (0, import_react2.createElement)(
-+ return (0, import_react3.createElement)(
- "span",
- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } },
+- "span",
+- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } },
- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }),
- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null,
- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null,
- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null,
- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null
-+ (0, import_react3.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }),
-+ badgeKind === "working" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null,
-+ badgeKind === "alert" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null,
-+ badgeKind === "admin" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react3.createElement)(StarIcon)) : null,
-+ badgeKind === "main" ? (0, import_react3.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react3.createElement)(MainStar)) : null
- );
- }
- function clusterSeats(count, frame) {
-@@ -2984,21 +2984,21 @@ function clusterSeats(count, frame) {
- 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;
+- );
+-}
+-function clusterSeats(count, frame) {
+- if (count === 1) return [[0, 0, frame]];
+- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]];
+- if (count === 3) {
+- const size = frame * 5 / 9;
+- const rest = frame - size;
+- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]];
+- }
+- const half = frame / 2;
+- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]];
+-}
+-function RoomAvatar({ room: room2, roster, size = 36 }) {
+- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+- const people = members.length === 1 ? [members[0], null] : members;
- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } });
- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size });
-+ const seat = (bot, seatSize, key) => bot ? (0, import_react3.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react3.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } });
-+ if (people.length === 0) return (0, import_react3.createElement)(BotMark, { size });
- if (size <= 20 && people.length > 1) {
+- if (size <= 20 && people.length > 1) {
- return (0, import_react2.createElement)(
-+ return (0, import_react3.createElement)(
- "span",
- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } },
- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)),
+- "span",
+- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } },
+- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)),
- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null
-+ people.length > 3 ? (0, import_react3.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null
- );
+- );
++ try {
++ visit(sheet.cssRules);
++ } catch {
}
- const seats = clusterSeats(Math.min(people.length, 4), size);
- const overflow = people.length > 4 ? people.length - 3 : 0;
- const gap = size / 14;
- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap }));
+- const seats = clusterSeats(Math.min(people.length, 4), size);
+- const overflow = people.length > 4 ? people.length - 3 : 0;
+- const gap = size / 14;
+- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap }));
- return (0, import_react2.createElement)(
-+ return (0, import_react3.createElement)(
- "span",
- { className: "bt-cluster", style: { width: size, height: size } },
- seats.map(([x, y, s], index) => {
-@@ -3006,75 +3006,75 @@ function RoomAvatar({ room: room2, roster, size = 36 }) {
- 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) {
+- "span",
+- { className: "bt-cluster", style: { width: size, height: size } },
+- seats.map(([x, y, s], index) => {
+- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`);
+- const style = { left: x, top: y, width: s, height: s };
+- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" });
+- if (overflow > 0 && index === 3) {
- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`);
-+ return (0, import_react3.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 names;
++}
++function compileFragmentSelectors(text, names) {
++ const compiled = /* @__PURE__ */ new Map();
++ return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => {
++ if (!compiled.has(fragment)) {
++ const exact = [];
++ for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`);
++ compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole);
++ }
++ return compiled.get(fragment);
++ });
++}
++function installCompiledStyles(text, tag) {
++ const style = installStyles("", tag);
++ const cache = /* @__PURE__ */ new WeakMap();
++ const render = () => {
++ const names = /* @__PURE__ */ new Set();
++ for (const sheet of document.styleSheets) {
++ if (sheet.ownerNode?.dataset?.dshBot) continue;
++ let list = cache.get(sheet);
++ if (!list) {
++ list = sheetClassNames(sheet);
++ cache.set(sheet, list);
}
- const bot = people[index];
+- const bot = people[index];
- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
-+ return (0, import_react3.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s));
- })
- );
+- })
+- );
++ for (const name of list) names.add(name);
++ }
++ const next = compileFragmentSelectors(text, names);
++ if (style.textContent !== next) style.textContent = next;
++ };
++ render();
++ let timer;
++ const schedule = () => {
++ clearTimeout(timer);
++ timer = setTimeout(render, 120);
++ };
++ const onLoad = (event) => {
++ if (event.target instanceof HTMLLinkElement) schedule();
++ };
++ const observer = new MutationObserver(schedule);
++ observer.observe(document.head, { childList: true });
++ document.addEventListener("load", onLoad, true);
++ return () => {
++ observer.disconnect();
++ document.removeEventListener("load", onLoad, true);
++ clearTimeout(timer);
++ style.remove();
++ };
}
// src/client/icons.js
@@ -6380,7 +6680,10 @@ diff --git a/client.js b/client.js
+ (0, import_react24.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, bot.name)),
+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add"))
-+ )),
+ )),
+- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
+- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null,
+- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)(
+ ...outsideRemotes.map((bot) => (0, import_react24.createElement)(
+ "button",
+ { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
@@ -6389,10 +6692,7 @@ diff --git a/client.js b/client.js
+ } },
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
- )),
-- (0, import_react23.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
-- ] : outside.length > 0 ? (0, import_react23.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react23.createElement)(PlusIcon), t("Add member")) : null,
-- (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)(
++ )),
+ (0, import_react24.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
+ ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react24.createElement)(PlusIcon), t("Add member")) : null,
+ (0, import_react24.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react24.createElement)(
diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js
index 8a7c962f0..70534b5ac 100644
--- a/patches/ds-bot/files/src/client/flybot-slots.js
+++ b/patches/ds-bot/files/src/client/flybot-slots.js
@@ -15,11 +15,15 @@
// flybot.session.dock { id, order, component({ sessionId, roster, room, bot, actions, call }) } above the composer
// flybot.new-chat.action { id, order, label, hint?, visible?, run({ close, actions, roster }) } a row in the sidebar '+' menu (next to Create group chat)
//
+// The same registry object also carries the host Bot look so a plugin can draw a Bot
+// exactly as the sidebar does: __flybot__.BotAvatar / BotMark / lookOf (set in registry()).
+//
// Types: ds-bot/types/flybot.d.ts (FlyBotClientRegistry).
import { Component, createElement as h, Fragment, useEffect, useState } from 'react'
import { call as callHost } from './api.js'
import { isListedRemoteChat } from '../shared/remote-label.js'
import { registry as baseRegistry } from './flybot-registry.js'
+import { BotAvatar, BotMark, lookOf } from './mark.js'
import { t } from './i18n.js'
import { createClientApi as createClientApiWith } from './flybot-registry.js'
@@ -32,6 +36,11 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa
export function registry() {
const slots = baseRegistry()
globalThis.__flybot__.call ??= call
+ // Classic-script plugins cannot import mark.js; they render the same Bot look
+ // the sidebar / session list / chat header use via these host components.
+ globalThis.__flybot__.BotMark ??= BotMark
+ globalThis.__flybot__.BotAvatar ??= BotAvatar
+ globalThis.__flybot__.lookOf ??= lookOf
return slots
}
diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts
index 2f85e4fc5..e951ca898 100644
--- a/patches/ds-bot/files/types/flybot.d.ts
+++ b/patches/ds-bot/files/types/flybot.d.ts
@@ -301,7 +301,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise
* How a client plugin gets it: a DSH web client bundle (`window.__ModuleLoader__.load({ id,
* inject: ['flybot'], ... })`, see plugins/tree/client.js) receives the browser service as
* `ctx.flybot` (a {@link FlyBotClient}). The same registry is also on `globalThis.__flybot__`
- * for code without a Cordis context. Slots below are FlyBot's own registry: register them
+ * for code without a Cordis context (`BotAvatar` / `BotMark` / `lookOf` are the host Bot
+ * look, set when the client registry() runs). Slots below are FlyBot's own registry: register them
* with `ctx.flybot.slots.register(name, entry)`, NOT the shell's `ctx.slots` (which throws
* `slot "flybot.…" is not declared`). Every `register` returns a disposer; call it from
* `ctx.effect` so a reload or uninstall removes the entry. Entries are sorted by `order`
@@ -367,9 +368,23 @@ export interface FlyBotClientRegistry {
subscribe(listener: () => void): () => void
}
+/** Host Bot look: same component the sidebar / session list / chat header render. */
+export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any
+export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any
+export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string }
+
declare global {
// eslint-disable-next-line no-var
- var __flybot__: { slots: FlyBotClientRegistry; call?: FlyBotCall; api?: FlyBotClient } | undefined
+ var __flybot__: {
+ slots: FlyBotClientRegistry
+ call?: FlyBotCall
+ api?: FlyBotClient
+ /** Same character mark as the sidebar / chat header. Set by the host client registry(). */
+ BotMark?: FlyBotBotMark
+ /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */
+ BotAvatar?: FlyBotBotAvatar
+ lookOf?: FlyBotLookOf
+ } | undefined
}
declare module 'cordis' {
diff --git a/plugins/social/README.md b/plugins/social/README.md
index 85323a520..be2b4f8e0 100644
--- a/plugins/social/README.md
+++ b/plugins/social/README.md
@@ -58,8 +58,8 @@ Tests (`test/social.test.js`) use a mocked server.
People in the same place (or on a call) put their Bots in one room by each typing the same 6-digit number. There is no host and no friend request.
1. Click **+** at the top of the sidebar (next to Search) and choose **Face-to-face group**, or open **Friends' Bots** and **+ Add** → **Face-to-face group**.
-2. Choose which of your Bots to bring (multi-select; default: the Main Bot). Type the same six digits as the people next to you. Click **Join**.
-3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room lists who is in (each Bot and its owner; yours marked **mine**) and how much time is left. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke.
+2. Choose which of your Bots to bring (tap tiles; default: the Main Bot). Each Bot shows the same avatar as in the sidebar. Type the same six digits as the people next to you. Click **Join**.
+3. You join at once — there is no preview. Several selected Bots join the same room (one join per Bot). If one of them fails, the others still join and the failure is shown. A waiting room is a wrapping grid of who is in (each Bot's avatar on top and name below; yours with a tiny accent dot). The countdown stays at the top; **Leave** and **Enter chat** share the footer. If a Bot has no Bot Social identity yet, FlyBot registers it for this room only, then joins — friends still cannot reach it unless you invite them to it. **Invite to this Bot** / **Manage access** (Bot settings) are how you grant and revoke.
4. **Enter chat** starts the group and opens it (voids the number). If someone else starts first, this screen opens the chat for you. The matching window is 10 minutes; when it ends or the room starts, the number is invalid.
5. **Leave** removes your Bots in one click. Anyone can leave; if the last member leaves, the room closes and the number is free again.
diff --git a/plugins/social/client.js b/plugins/social/client.js
index 90bfe5908..6bed73a62 100644
--- a/plugins/social/client.js
+++ b/plugins/social/client.js
@@ -58,7 +58,10 @@ window.__ModuleLoader__.load({
.bt-social-rows{display:flex;flex-direction:column}
.fs-row{display:flex;align-items:center;gap:8px;min-width:0;padding:5px 8px 5px 10px;border-radius:8px;transition:background-color .12s ease}
.fs-row:hover{background:var(--bt-hover,rgba(119,119,119,.09))}
-.fs-avatar{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;font-weight:600}
+.fs-avatar{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;font-weight:600;overflow:hidden}
+.fs-avatar img{width:100%;height:100%;object-fit:cover;display:block}
+.fs-face-mark{flex:none;display:inline-flex;align-items:center;justify-content:center;width:var(--fs-face-size,24px);height:var(--fs-face-size,24px)}
+.fs-face-mark .bt-mark,.fs-face-mark .fs-avatar{width:100%;height:100%;font-size:calc(var(--fs-face-size,24px)*.5)}
.fs-text{display:flex;flex-direction:column;flex:1 1 auto;min-width:0}
.fs-name{font-size:14px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fs-sub{font-size:12px;line-height:16px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -106,15 +109,27 @@ window.__ModuleLoader__.load({
.fs-menu button:hover{background:var(--bt-hover,rgba(119,119,119,.09))}
.fs-chat-approval{margin:4px 0 8px}
.fs-dialog-back{position:fixed;inset:0;z-index:40;background:rgba(20,20,20,.4);display:flex;align-items:center;justify-content:center;padding:12px}
-.fs-dialog{width:min(420px,100%);max-height:min(640px,92vh);overflow:auto;margin:0;padding:14px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:14px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
-.fs-dialog-head{display:flex;align-items:center;gap:8px}
+.fs-dialog{width:min(420px,100%);max-height:min(640px,92vh);overflow:hidden;margin:0;padding:14px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:14px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:10px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
+.fs-dialog-head{display:flex;align-items:center;gap:8px;flex:none}
.fs-dialog-head .fs-name{flex:1 1 auto}
-.fs-pick{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px;max-height:220px;overflow:auto}
-.fs-pick li{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px}
-.fs-pick li:hover{background:var(--bt-hover,rgba(119,119,119,.09))}
-.fs-pick label{display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:0;cursor:pointer;font:inherit}
-.fs-wait-roster{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto}
-.fs-wait-roster li{display:flex;align-items:center;gap:8px;padding:6px 4px;border-radius:8px}
+.fs-dialog > form{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px}
+.fs-tiles{margin:0;padding:4px 0 0;list-style:none;display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:10px 6px;align-content:start}
+.fs-pick{max-height:220px;overflow:auto}
+.fs-tile{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;padding:6px 2px;border-radius:10px}
+.fs-tile-hit{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;width:100%;margin:0;cursor:pointer;font:inherit;position:relative}
+.fs-tile-hit:hover,.fs-tile-on{background:var(--bt-hover,rgba(119,119,119,.09))}
+.fs-tile-mark{position:relative;flex:none;display:inline-flex}
+.fs-tile-name{display:block;width:100%;font-size:12px;line-height:16px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--bt-ink,#141414)}
+.fs-tile-mine{position:absolute;right:-1px;bottom:-1px;width:8px;height:8px;border-radius:50%;background:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px var(--bt-card,#fff);pointer-events:none}
+.fs-tile-on .fs-face-mark{border-radius:50%;box-shadow:0 0 0 2px var(--bt-accent,#4d6bfe)}
+.fs-tile-check{position:absolute;right:-2px;bottom:-2px;width:16px;height:16px;border-radius:50%;background:var(--bt-accent,#4d6bfe);color:#fff;font-size:11px;line-height:16px;text-align:center;pointer-events:none;box-shadow:0 0 0 2px var(--bt-card,#fff)}
+.fs-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
+.fs-wait{display:flex;flex-direction:column;gap:8px;flex:1 1 auto;min-height:0;overflow:hidden}
+.fs-wait-count{flex:none;padding:0}
+.fs-wait-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px}
+.fs-wait-foot{display:flex;align-items:stretch;gap:8px;flex:none}
+.fs-wait-foot .fs-btn{height:36px}
+.fs-wait-foot .fs-btn-primary{flex:1 1 auto;width:auto;min-width:0}
.fs-fail{padding:0}
`
function useStyles() {
@@ -128,6 +143,126 @@ window.__ModuleLoader__.load({
}
const initial = name => (String(name ?? '?').replace(/^@/, '').trim()[0] ?? '?').toUpperCase()
+ // Same FNV-1a as core lookOf, so a missing host BotMark still paints a stable per-Bot
+ // color (never the shared gray letter badge) and two ids do not collide.
+ const FACE_INKS = ['#e5484d', '#e54666', '#f76b15', '#ffc53d', '#46a758', '#12a594', '#0090ff', '#3e63dd', '#5b5bd6', '#8e4ec6']
+ function inkOf(seed) {
+ const text = String(seed ?? '')
+ let value = 2166136261
+ for (let i = 0; i < text.length; i++) value = Math.imul(value ^ text.charCodeAt(i), 16777619)
+ return FACE_INKS[(value >>> 0) % FACE_INKS.length]
+ }
+ function faceSeed(bot) {
+ const handle = typeof bot?.remote?.handle === 'string' && bot.remote.handle.trim()
+ ? bot.remote.handle
+ : typeof bot?.handle === 'string' && bot.handle.trim() && bot.remote
+ ? bot.handle
+ : ''
+ if (handle) return `remote:${handle.trim().replace(/^@+/, '').toLowerCase()}`
+ if (bot?.id !== undefined) return String(bot.id)
+ if (typeof bot?.handle === 'string' && bot.handle.trim()) return `remote:${bot.handle.trim().replace(/^@+/, '').toLowerCase()}`
+ return String(bot?.name ?? '')
+ }
+ function rosterBot(roster, id) {
+ if (!id) return undefined
+ return roster?.byId?.[id] ?? (roster?.bots ?? []).find(bot => bot.id === id)
+ }
+ function chatByHandle(remoteChats, handle) {
+ if (!handle) return undefined
+ return (remoteChats ?? []).find(chat => chat.remote?.handle === handle)
+ }
+ // Local picker rows use the team roster record (configured image / shape / color).
+ // Waiting-room "mine" matches the same local Bot; a friend's Bot uses its chat
+ // record or a handle-seeded remote look, plus any avatar the member payload carries.
+ function faceLook({ roster, remoteChats, bots, local, member } = {}) {
+ if (local) {
+ const full = rosterBot(roster, local.id)
+ return full ? { ...local, ...full } : local
+ }
+ if (!member) return undefined
+ const mine = (bots ?? []).find(bot => bot.handle && bot.handle === member.handle)
+ if (mine) return faceLook({ roster, bots, local: mine })
+ const extra = {}
+ if (member.color) extra.color = member.color
+ if (member.avatar) extra.avatar = member.avatar
+ const chat = chatByHandle(remoteChats, member.handle)
+ if (chat) {
+ return {
+ ...chat,
+ name: member.displayName || chat.name,
+ remote: chat.remote ?? { handle: member.handle },
+ ...extra,
+ }
+ }
+ return {
+ id: member.botId || member.handle,
+ name: member.displayName || member.label || member.handle,
+ handle: member.handle,
+ remote: member.handle ? { handle: member.handle } : undefined,
+ ...extra,
+ }
+ }
+ function FaceAvatar({ bot, size = 24 } = {}) {
+ const Avatar = globalThis.__flybot__?.BotAvatar
+ const Mark = globalThis.__flybot__?.BotMark
+ const seed = faceSeed(bot)
+ const color = typeof bot?.color === 'string' && bot.color.startsWith('#') ? bot.color : inkOf(seed)
+ let inner
+ if (typeof Avatar === 'function' && bot) inner = h(Avatar, { bot, size, badge: false, live: false })
+ else if (typeof Mark === 'function' && bot) inner = h(Mark, { bot, size })
+ else {
+ const image = bot?.avatar?.image
+ const emoji = bot?.avatar?.emoji
+ inner = h('span', {
+ className: 'fs-avatar',
+ 'data-color': color,
+ style: image ? undefined : { background: color, color: '#fff' },
+ }, image ? h('img', { src: image, alt: '' }) : (emoji || initial(bot?.name)))
+ }
+ return h('span', {
+ className: 'fs-face-mark',
+ 'data-flybot': 'face-avatar',
+ 'data-bot-id': bot?.id,
+ 'data-handle': bot?.remote?.handle || bot?.handle,
+ 'data-color': color,
+ 'data-size': size,
+ 'aria-hidden': true,
+ style: { width: size, height: size, '--fs-face-size': `${size}px` },
+ }, inner)
+ }
+
+ function FaceTile({ bot, name, handle, mine, pick, selected, onToggle } = {}) {
+ const mark = h('span', { className: 'fs-tile-mark' },
+ h(FaceAvatar, { bot, size: 48 }),
+ mine
+ ? h('span', { className: 'fs-tile-mine', 'data-flybot': 'face-mine', title: 'mine', 'aria-label': 'mine' })
+ : null,
+ pick && selected
+ ? h('span', { className: 'fs-tile-check', 'data-flybot': 'face-pick-check', 'aria-hidden': true }, '✓')
+ : null)
+ const caption = h('span', { className: 'fs-tile-name', title: name }, name)
+ if (pick) {
+ return h('li', { className: selected ? 'fs-tile fs-tile-on' : 'fs-tile' },
+ h('label', { className: 'fs-tile-hit' },
+ h('input', {
+ type: 'checkbox',
+ className: 'fs-sr',
+ checked: !!selected,
+ 'aria-label': name,
+ onChange: onToggle,
+ }),
+ mark,
+ caption))
+ }
+ return h('li', {
+ className: 'fs-tile',
+ 'data-flybot': 'face-member',
+ 'data-mine': mine || undefined,
+ 'data-handle': handle,
+ title: name,
+ }, mark, caption)
+ }
+
const AUTO_SEND_NOTE = 'When this is on, your Bots post in face-to-face rooms without asking each time.'
function FaceAutoSend({ on, busy, onChange, error } = {}) {
@@ -235,7 +370,7 @@ window.__ModuleLoader__.load({
}
// Separate Face-to-face group screen (#92): pick Bots, join by number, live waiting room.
- function FaceToFaceGroup({ actions, onClose } = {}) {
+ function FaceToFaceGroup({ actions, onClose, roster, remoteChats } = {}) {
const [step, setStep] = useState('setup')
const [bots, setBots] = useState([])
const [selected, setSelected] = useState([])
@@ -373,43 +508,39 @@ window.__ModuleLoader__.load({
h('div', { className: 'fs-dialog-head' },
h('div', { className: 'fs-name' }, 'Face-to-face group'),
h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: () => onClose?.() }, 'Close')),
- waiting ? h('div', { 'data-flybot': 'face-wait' },
- h('div', { className: 'fs-hint', 'data-tick': tick }, candidate
+ waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' },
+ h('div', { className: 'fs-hint fs-wait-count', 'data-flybot': 'face-wait-count', 'data-tick': tick }, candidate
? (room.expiresAt ? countdown(room.expiresAt) : 'Matching window open. Anyone with the number can still join.')
: 'The room is open. The number is no longer valid.'),
- h('ul', { className: 'fs-wait-roster', 'aria-label': 'Who has joined' },
- members.map(member => {
- const mine = memberIsMine(member, bots, room)
- return h('li', { key: member.handle || member.id, 'data-flybot': 'face-member', 'data-mine': mine || undefined, 'data-handle': member.handle },
- h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(member.displayName || member.label || member.handle)),
- h('span', { className: 'fs-text' },
- h('span', { className: 'fs-name' }, member.displayName || member.label || member.handle),
- member.ownerName ? h('span', { className: 'fs-sub' }, member.ownerName) : null),
- mine ? h('span', { className: 'fs-tag', 'data-flybot': 'face-mine' }, 'mine') : null)
- })),
- members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null,
- failures.length ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' },
- failures.map(item => `${item.name}: ${item.text}`).join(' ')) : null,
- status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null,
- h('div', { className: 'fs-face-actions' },
+ h('div', { className: 'fs-wait-body' },
+ h('ul', { className: 'fs-tiles fs-wait-roster', 'data-flybot': 'face-wait-roster', 'aria-label': 'Who has joined' },
+ members.map(member => h(FaceTile, {
+ key: member.handle || member.id,
+ bot: faceLook({ roster, remoteChats, bots, member }),
+ name: member.displayName || member.label || member.handle,
+ handle: member.handle,
+ mine: memberIsMine(member, bots, room),
+ }))),
+ members.length === 0 ? h('div', { className: 'fs-hint' }, 'Waiting for others to join…') : null,
+ failures.length ? h('div', { className: 'fs-status', 'data-tone': 'error', 'data-flybot': 'face-join-fail', role: 'status' },
+ failures.map(item => `${item.name}: ${item.text}`).join(' ')) : null,
+ status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null),
+ h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-wait-foot' },
+ h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-leave', disabled: !!busy, onClick: leave }, 'Leave'),
h('button', {
type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-enter',
disabled: !!busy || !room.roomId, onClick: enter,
- }, 'Enter chat'),
- h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-leave', disabled: !!busy, onClick: leave }, 'Leave'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' },
+ }, 'Enter chat'))) : h('form', { onSubmit: join, 'data-flybot': 'face-join' },
h('div', { className: 'fs-hint' }, 'Choose which of your Bots to bring, then type the same 6-digit number as the people next to you. You join at once — there is no preview.'),
- h('ul', { className: 'fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' },
- bots.map(bot => h('li', { key: bot.id },
- h('label', null,
- h('input', {
- type: 'checkbox', checked: selected.includes(bot.id),
- 'aria-label': bot.name,
- onChange: () => toggle(bot.id),
- }),
- h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(bot.name)),
- h('span', { className: 'fs-text' },
- h('span', { className: 'fs-name' }, bot.name),
- bot.isMain ? h('span', { className: 'fs-sub' }, 'Main Bot') : null))))),
+ h('ul', { className: 'fs-tiles fs-pick', 'data-flybot': 'face-pick', 'aria-label': 'Your Bots' },
+ bots.map(bot => h(FaceTile, {
+ key: bot.id,
+ bot: faceLook({ roster, local: bot }),
+ name: bot.name,
+ pick: true,
+ selected: selected.includes(bot.id),
+ onToggle: () => toggle(bot.id),
+ }))),
bots.length === 0 ? h('div', { className: 'fs-hint' }, 'Loading your Bots…') : null,
h('div', { className: 'fs-hint', 'data-flybot': 'face-join-room' },
'Available to this room only. Friends cannot see these Bots unless you invite them.'),
@@ -682,7 +813,7 @@ window.__ModuleLoader__.load({
return () => { if (faceOpenHandler === fn) faceOpenHandler = null }
}
- function FriendsBots({ renderBot, remoteChats, actions } = {}) {
+ function FriendsBots({ renderBot, remoteChats, actions, roster } = {}) {
useStyles()
const [data, setData] = useState(() => readCachedRoster())
const [error, setError] = useState('')
@@ -785,7 +916,7 @@ window.__ModuleLoader__.load({
h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-group-open', onClick: () => setFaceOpen(true) }, 'Face-to-face group'),
h('div', { className: 'fs-hint' }, 'Meet the people next to you. Pick your Bots and type the same 6-digit number.')) : null,
adding && !unavailable ? h(AddFriend, { onChange: () => load().then(loadSettings) }) : null,
- faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, onClose: () => setFaceOpen(false) }) : null,
+ faceOpen && !unavailable ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null,
unavailable ? h('div', { className: 'fs-empty', 'data-flybot': 'social-unavailable' }, error) : error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null,
homes.map(entry => {
const shut = closed[entry.home] === true
@@ -1012,6 +1143,7 @@ window.__ModuleLoader__.load({
ChatApprovalDock,
FriendBotRow,
FaceToFaceGroup,
+ FaceAvatar,
FaceRoomOverview,
FaceAutoSend,
apply(ctx) {
diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js
index 478b0e433..9c6e08dd3 100644
--- a/plugins/social/src/face-rooms.js
+++ b/plugins/social/src/face-rooms.js
@@ -211,6 +211,25 @@ export function platformStartedText(reason) {
return 'The room is now open. The number is no longer valid.'
}
+// Peer look is optional: only pass through a stored color / avatar when the payload
+// actually has one. Image URLs stay one line, no markup; a missing look is fine — the
+// client then uses the host BotMark seeded by handle.
+export function faceMemberLook(member) {
+ if (!member || typeof member !== 'object') return {}
+ const out = {}
+ const color = typeof member.color === 'string' ? member.color.trim().slice(0, 32) : ''
+ if (color && !/[\n<>]/.test(color)) out.color = color
+ const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {}
+ const avatar = {}
+ if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape
+ const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '')
+ if (image && image.length < 2048 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image
+ const emoji = typeof src.emoji === 'string' ? src.emoji.trim() : (typeof member.emoji === 'string' ? member.emoji.trim() : '')
+ if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 8)
+ if (Object.keys(avatar).length) out.avatar = avatar
+ return out
+}
+
export function viewFaceMember(member) {
if (!member || typeof member !== 'object') return undefined
const handle = normHandle(member.handle)
@@ -221,6 +240,7 @@ export function viewFaceMember(member) {
ownerName: cleanFaceName(member.owner_name || member.ownerName, ''),
roomOnly: member.roomOnly === true || member.published !== true,
label: cleanFaceName(member.label, '') || faceMemberLabel(member),
+ ...faceMemberLook(member),
}
}
diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js
index be7e8d3e7..9d5c3a5cf 100644
--- a/plugins/social/test/face-rooms.test.js
+++ b/plugins/social/test/face-rooms.test.js
@@ -10,6 +10,8 @@ import {
faceUntrustedText,
viewFaceEvent,
viewFaceRoom,
+ viewFaceMember,
+ faceMemberLook,
viewMe,
renderFaceEvent,
rememberEvent,
@@ -288,6 +290,20 @@ test('viewFaceRoom ignores a missing id', () => {
assert.equal(viewFaceRoom({ room_id: 'frm_1', status: 'candidate', members: [] }).roomId, 'frm_1')
})
+test('viewFaceMember keeps a friend Bot avatar when the payload has one', () => {
+ const plain = viewFaceMember({ handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob' })
+ assert.equal(plain.avatar, undefined)
+ assert.equal(plain.color, undefined)
+ const pictured = viewFaceMember({
+ handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob',
+ avatar: { image: 'https://example.test/coder.png', shape: 'star' }, color: 'green',
+ })
+ assert.deepEqual(pictured.avatar, { image: 'https://example.test/coder.png', shape: 'star' })
+ assert.equal(pictured.color, 'green')
+ assert.equal(faceMemberLook({ image: 'https://example.test/x.png' }).avatar.image, 'https://example.test/x.png')
+ assert.equal(faceMemberLook({ avatar: { image: 'javascript:alert(1)' } }).avatar, undefined)
+})
+
test('GET/PATCH /v1/me auto_approve_face_rooms, including in-place confirmation', async () => {
const server = faceServer()
const api = createFaceRoomApi({ server: 'https://s', token: 'otk_test', fetch: server.fetch })
diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js
index 3ed0aa4f0..88856edc7 100644
--- a/plugins/social/test/social.test.js
+++ b/plugins/social/test/social.test.js
@@ -1247,6 +1247,19 @@ function loadSocialClient(callImpl) {
}
runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox)
const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() })
+ const stubMark = (kind) => ({ bot, size } = {}) => h('span', {
+ className: 'bt-mark',
+ 'data-flybot': kind,
+ 'data-bot-id': bot?.id,
+ 'data-handle': bot?.remote?.handle || bot?.handle,
+ 'data-shape': bot?.avatar?.shape,
+ 'data-image': bot?.avatar?.image,
+ 'data-color': bot?.color,
+ 'data-emoji': bot?.avatar?.emoji,
+ 'data-size': size,
+ })
+ sandbox.__flybot__.BotAvatar = stubMark('bot-avatar')
+ sandbox.__flybot__.BotMark = stubMark('bot-mark')
const walk = (node, all = []) => {
if (!node || typeof node !== 'object') return all
if (typeof node.type === 'function') return walk(node.type(node.props), all)
@@ -1264,7 +1277,7 @@ function loadSocialClient(callImpl) {
useMemo: f => f(),
}))
}
- return { registered, entries, walk, pluginOf, h }
+ return { registered, entries, walk, pluginOf, h, sandbox }
}
test('social HTTP keep-alive reuses one Agent', () => {
@@ -1540,18 +1553,49 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch
return {}
})
const bots = [{ id: 'a', name: 'Chief', isMain: true, handle: '@a.me.home' }, { id: 'b', name: 'Writer', handle: '@b.me.home' }]
- const setup = (states) => walk(pluginOf(states).FaceToFaceGroup({ actions: { openSession: id => opened.push(id) } }))
+ const roster = {
+ byId: {
+ a: { id: 'a', name: 'Chief', isMain: true, color: 'blue', avatar: { shape: 'whale' } },
+ b: { id: 'b', name: 'Writer', color: 'orange', avatar: { shape: 'star' } },
+ },
+ bots: [
+ { id: 'a', name: 'Chief', isMain: true, color: 'blue', avatar: { shape: 'whale' } },
+ { id: 'b', name: 'Writer', color: 'orange', avatar: { shape: 'star' } },
+ ],
+ }
+ const remoteChats = [{
+ id: 'r-coder', name: 'Coder', remote: { handle: '@coder.bob.home' },
+ color: 'green', avatar: { image: 'https://example.test/coder.png' },
+ }]
+ const faceProps = { actions: { openSession: id => opened.push(id) }, roster, remoteChats }
+ const setup = (states) => walk(pluginOf(states).FaceToFaceGroup(faceProps))
const picker = setup(['setup', bots, ['a'], '', false, null, [], null, 0])
assert.ok(picker.some(n => n.props['data-flybot'] === 'face-group'))
- assert.ok(picker.some(n => n.props['data-flybot'] === 'face-pick'))
+ assert.ok(picker.some(n => n.props['data-flybot'] === 'face-pick' && /\bfs-tiles\b/.test(n.props.className)))
assert.ok(picker.some(n => n.props['data-flybot'] === 'face-join'))
+ const pickAvatars = picker.filter(n => n.props['data-flybot'] === 'face-avatar')
+ assert.equal(pickAvatars.length, 2)
+ const chiefMark = picker.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a')
+ const writerMark = picker.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'b')
+ assert.ok(chiefMark, 'Chief uses the host BotAvatar')
+ assert.ok(writerMark, 'Writer uses the host BotAvatar')
+ assert.equal(chiefMark.props['data-shape'], 'whale')
+ assert.equal(writerMark.props['data-shape'], 'star')
+ assert.notEqual(chiefMark.props['data-shape'], writerMark.props['data-shape'])
+ assert.equal(chiefMark.props['data-size'], 48)
+ assert.equal(writerMark.props['data-size'], 48)
+ assert.equal(picker.filter(n => n.props.className === 'fs-avatar' && n.props['data-flybot'] === undefined).length, 0)
+ assert.ok(picker.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief')))
+ assert.ok(picker.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer')))
+ assert.equal(picker.some(n => n.props.className === 'fs-sub' && n.children.includes('Main Bot')), false)
const hint = picker.find(n => n.props['data-flybot'] === 'face-join-room')
assert.ok(hint)
assert.match(hint.children.join(''), /Available to this room only/)
assert.equal(picker.some(n => /published/i.test(String(n.children ?? ''))), false)
assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Chief' && n.props.checked === true))
assert.ok(picker.some(n => n.type === 'input' && n.props.type === 'checkbox' && n.props['aria-label'] === 'Writer' && n.props.checked === false))
+ assert.equal(picker.filter(n => n.props['data-flybot'] === 'face-pick-check').length, 1)
const short = setup(['setup', bots, ['a'], '12ab', false, null, [], null, 0])
await short.find(n => n.type === 'form' && n.props['data-flybot'] === 'face-join').props.onSubmit({ preventDefault() {} })
@@ -1583,13 +1627,40 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch
}
const wait = setup(['waiting', bots, ['a'], '123456', false, null, [{ botId: 'b', name: 'Writer', text: 'This Bot cannot join that room.' }], room, 0])
assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait'))
+ assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-roster' && /\bfs-tiles\b/.test(n.props.className)))
const mine = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@a.me.home')
const peer = wait.find(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@coder.bob.home')
assert.equal(mine.props['data-mine'], true)
- assert.ok(wait.some(n => n.props['data-flybot'] === 'face-mine' && n.children.includes('mine')))
+ const mineDot = wait.find(n => n.props['data-flybot'] === 'face-mine')
+ assert.ok(mineDot)
+ assert.equal(mineDot.props.className, 'fs-tile-mine')
+ assert.equal(mineDot.props['aria-label'], 'mine')
+ assert.equal(mineDot.props.title, 'mine')
+ assert.equal(mineDot.children.includes('mine'), false)
assert.equal(peer.props['data-mine'], undefined)
- assert.ok(wait.some(n => n.children.includes('Me')))
- assert.ok(wait.some(n => n.children.includes('Bob')))
+ const mineMark = wait.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a')
+ const peerMark = wait.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-handle'] === '@coder.bob.home')
+ assert.ok(mineMark, 'waiting roster uses the same Chief avatar as the picker')
+ assert.equal(mineMark.props['data-shape'], 'whale')
+ assert.equal(mineMark.props['data-size'], 48)
+ assert.ok(peerMark, "a friend's Bot uses its own look when the chat record has one")
+ assert.equal(peerMark.props['data-image'], 'https://example.test/coder.png')
+ assert.equal(peerMark.props['data-size'], 48)
+ assert.notEqual(mineMark.props['data-bot-id'], peerMark.props['data-bot-id'])
+ assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Chief')))
+ assert.ok(wait.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Coder')))
+ assert.equal(mine.children.some(n => n.props?.className === 'fs-sub' || (typeof n.props?.className === 'string' && n.props.className.includes('fs-tag'))), false)
+ assert.equal(peer.children.some(n => n.props?.className === 'fs-sub'), false)
+ assert.equal(wait.some(n => n.props['data-flybot'] === 'face-owner'), false)
+ assert.equal(wait.some(n => n.props.className === 'fs-wait-hint' || n.props.className === 'fs-wait-name'), false)
+ assert.ok(wait.some(n => n.props['data-flybot'] === 'face-wait-count'))
+ const foot = wait.find(n => n.props['data-flybot'] === 'face-wait-foot')
+ assert.ok(foot)
+ const footBtns = (foot.children ?? []).filter(n => n && typeof n === 'object')
+ assert.equal(footBtns[0].props['data-flybot'], 'face-leave')
+ assert.equal(footBtns[1].props['data-flybot'], 'face-enter')
+ assert.equal(footBtns[0].props.className, 'fs-btn')
+ assert.match(footBtns[1].props.className, /fs-btn-primary/)
assert.match(wait.find(n => n.props['data-flybot'] === 'face-join-fail').children.join(''), /Writer:/)
assert.equal(wait.some(n => /published/i.test(String(n.children ?? ''))), false)
const enter = wait.find(n => n.props['data-flybot'] === 'face-enter')
@@ -1636,7 +1707,9 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch
cursor = 0
const liveNodes = liveClient.walk(livePlugin.FaceToFaceGroup({ actions: {} }))
assert.ok(liveNodes.some(n => n.props['data-flybot'] === 'face-member' && n.props['data-handle'] === '@writer.alice.home'))
- assert.ok(liveNodes.some(n => n.children.includes('Alice')))
+ assert.ok(liveNodes.some(n => n.props.className === 'fs-tile-name' && n.children.includes('Writer')))
+ assert.equal(liveNodes.some(n => n.props['data-flybot'] === 'face-owner'), false)
+ assert.equal(liveNodes.some(n => Array.isArray(n.children) && n.children.includes('Alice')), false)
const autoOpened = []
const autoPlugin = pluginOf(['waiting', bots, ['a'], '123456', false, null, [], { ...room, status: 'open' }, 0], true)
@@ -1644,6 +1717,21 @@ test('Face-to-face group (#92): pick Bots, join each once, live roster, Enter ch
assert.deepEqual(autoOpened, ['g1', 'close'])
})
+test('Face-to-face pick list still paints a distinct per-Bot color without host BotAvatar', () => {
+ const { walk, pluginOf, sandbox } = loadSocialClient(async () => ({ bots: [] }))
+ delete sandbox.__flybot__.BotAvatar
+ delete sandbox.__flybot__.BotMark
+ const bots = [{ id: 'chief-id', name: 'Chief', isMain: true }, { id: 'writer-id', name: 'Writer' }]
+ const tree = walk(pluginOf(['setup', bots, ['chief-id'], '', false, null, [], null, 0]).FaceToFaceGroup({}))
+ const avatars = tree.filter(n => n.props['data-flybot'] === 'face-avatar')
+ assert.equal(avatars.length, 2)
+ assert.equal(avatars[0].props['data-bot-id'], 'chief-id')
+ assert.equal(avatars[1].props['data-bot-id'], 'writer-id')
+ assert.notEqual(avatars[0].props['data-color'], avatars[1].props['data-color'])
+ const letters = tree.filter(n => n.props.className === 'fs-avatar').map(n => n.children.join(''))
+ assert.deepEqual(letters.sort(), ['C', 'W'])
+})
+
test('face-to-face: several local Bots join the same number and leave together', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)