diff --git a/changelog.d/939-ticket-seat-lines.fixed.md b/changelog.d/939-ticket-seat-lines.fixed.md
new file mode 100644
index 000000000..4976115a0
--- /dev/null
+++ b/changelog.d/939-ticket-seat-lines.fixed.md
@@ -0,0 +1 @@
+A group joined through an invite link now shows the other home's messages in the conversation, on both sides, including the host's recent lines from before the guest joined ([#939](https://github.com/ThinkFlowLab/FlyBot/issues/939)). The member count includes the remote Bots the roster already lists.
diff --git a/core/client.js b/core/client.js
index 2fcb42e43..fc68c2bbe 100644
--- a/core/client.js
+++ b/core/client.js
@@ -9135,205 +9135,8 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
};
}
-// src/client/palette.js
-var import_react16 = require("react");
-var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
-var PALETTE_EXIT_MS = 160;
-function CommandPalette({ start, token, roster, actions, useSessions }) {
- const [query, setQuery] = (0, import_react16.useState)(start ?? "");
- const [cursor, setCursor] = (0, import_react16.useState)(0);
- const [leaving, setLeaving] = (0, import_react16.useState)(false);
- const wanted = query.trim().toLowerCase();
- const close = () => {
- if (leaving) return;
- setLeaving(true);
- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
- };
- useEscape(close);
- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
- const found = useSessions((list) => {
- if (wanted.length < 2) return "[]";
- const hits = [];
- for (const id of ids) {
- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
- if (!Array.isArray(outline)) continue;
- const hidden = roster.exchangeTurns[id] ?? [];
- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
- const entry = outline[index2];
- if (hidden.includes(entry.turn)) continue;
- for (const raw of [entry.response, entry.prompt]) {
- const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim();
- const at = text.toLowerCase().indexOf(wanted);
- if (at < 0) continue;
- const from = Math.max(0, at - 24);
- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]);
- break;
- }
- }
- }
- return JSON.stringify(hits);
- });
- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
- const sections = [];
- const people2 = [
- ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
- key: bot.id,
- avatar: (0, import_react16.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
- title: pickerBotLabel(bot),
- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
- badge: bot.hidden ? t("Hidden") : void 0,
- run: async () => {
- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
- actions.openSession(bot.id);
- }
- })),
- ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
- key: room2.id,
- avatar: (0, import_react16.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
- title: room2.name,
- subtitle: t(room2.members.length === 1 ? "{count} member" : "{count} members", { count: room2.members.length }),
- badge: room2.hidden ? t("Hidden") : void 0,
- run: async () => {
- if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
- actions.openSession(room2.id);
- }
- }))
- ];
- if (people2.length) sections.push([t("Bots"), people2]);
- const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
- key: agent.id,
- avatar: (0, import_react16.createElement)("span", { className: "bt-palette-agent" }, (0, import_react16.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
- title: "DSH Agent",
- subtitle: t("Plain dsh session"),
- run: () => actions.openAgent(agent.id)
- }));
- if (agents.length) sections.push(["DSH Agent", agents]);
- const messages = JSON.parse(found).map(([id, turn, snippet]) => {
- const bot = roster.byId[id];
- const room2 = roster.roomsById[id];
- return {
- key: `${id}:${turn}`,
- title: bot?.name ?? room2?.name ?? t("Bot"),
- subtitle: snippet,
- avatar: bot ? (0, import_react16.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react16.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
- run: () => actions.openSession(id)
- };
- });
- if (messages.length) sections.push([t("Messages"), messages]);
- const commands = [
- { key: "cmd:new", icon: (0, import_react16.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
- { key: "cmd:bot", icon: (0, import_react16.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
- { key: "cmd:group", icon: (0, import_react16.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
- { key: "cmd:settings", icon: (0, import_react16.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
- ].filter((command) => matchesName(command.title));
- if (commands.length) sections.push([t("Commands"), commands]);
- const rows = sections.flatMap(([, items]) => items);
- const active = Math.min(cursor, Math.max(rows.length - 1, 0));
- const shown = rows.map((row) => row.key).join("\n");
- const glide = useGlide(`${active}
-${shown}`, { axis: "y", ...QUICK_EDGES });
- const listRef = glide.box;
- useFlip(listRef, shown, "[data-flip]");
- (0, import_react16.useEffect)(() => {
- listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
- }, [active, query]);
- const choose = (row) => {
- if (!row || leaving) return;
- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
- close();
- };
- let index = -1;
- return (0, import_react16.createElement)(
- "div",
- { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
- (0, import_react16.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
- (0, import_react16.createElement)(
- "div",
- { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
- (0, import_react16.createElement)(
- "div",
- { className: "bt-palette-head" },
- (0, import_react16.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react16.createElement)(SearchIcon)),
- (0, import_react16.createElement)("input", {
- autoFocus: true,
- value: query,
- placeholder: t("Search Bots, chats and messages"),
- "aria-label": t("Search"),
- role: "combobox",
- "aria-expanded": true,
- "aria-controls": "bt-palette-list",
- onChange: (event) => {
- setQuery(event.target.value);
- setCursor(0);
- },
- onKeyDown: (event) => {
- if (event.nativeEvent.isComposing) return;
- if (event.key === "ArrowDown") {
- event.preventDefault();
- setCursor(Math.min(active + 1, rows.length - 1));
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- setCursor(Math.max(active - 1, 0));
- } else if (event.key === "Enter") {
- event.preventDefault();
- choose(rows[active]);
- }
- }
- }),
- query ? (0, import_react16.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
- setQuery("");
- setCursor(0);
- } }, (0, import_react16.createElement)(CloseIcon)) : null
- ),
- (0, import_react16.createElement)(
- "div",
- { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
- (0, import_react16.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- rows.length === 0 ? (0, import_react16.createElement)(
- "div",
- { className: "bt-palette-empty" },
- (0, import_react16.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react16.createElement)(SearchIcon)),
- (0, import_react16.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
- (0, import_react16.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
- ) : sections.map(([title, items]) => (0, import_react16.createElement)(
- import_react16.Fragment,
- { key: title },
- (0, import_react16.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
- items.map((row) => {
- index += 1;
- const at = index;
- return (0, import_react16.createElement)(
- "button",
- {
- key: row.key,
- type: "button",
- role: "option",
- className: "bt-palette-row",
- "aria-selected": at === active,
- "data-flip": row.key,
- onMouseMove: () => {
- if (cursor !== at) setCursor(at);
- },
- onClick: () => choose(row)
- },
- (0, import_react16.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react16.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
- (0, import_react16.createElement)(
- "span",
- { className: "bt-palette-text" },
- (0, import_react16.createElement)("span", { className: "bt-palette-title" }, row.title),
- row.subtitle ? (0, import_react16.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
- ),
- row.badge ? (0, import_react16.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
- );
- })
- ))
- )
- )
- );
-}
-
// src/client/model-menu.js
-var import_react17 = require("react");
+var import_react16 = require("react");
var import_react_dom6 = require("react-dom");
var MENU_EXIT_MS = 120;
function groupModels(models) {
@@ -9356,8 +9159,8 @@ function groupModels(models) {
var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
var effortCache = /* @__PURE__ */ new Map();
function useEfforts(served, ref, actions) {
- const [fetched, setFetched] = (0, import_react17.useState)(void 0);
- (0, import_react17.useEffect)(() => {
+ const [fetched, setFetched] = (0, import_react16.useState)(void 0);
+ (0, import_react16.useEffect)(() => {
setFetched(void 0);
if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0;
let live = true;
@@ -9382,12 +9185,12 @@ function effortRows(reasoning) {
];
}
function EffortRows({ reasoning, current, onPick }) {
- return (0, import_react17.createElement)(
- import_react17.Fragment,
+ return (0, import_react16.createElement)(
+ import_react16.Fragment,
null,
- (0, import_react17.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react17.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
- effortRows(reasoning).map((row) => (0, import_react17.createElement)(
+ (0, import_react16.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react16.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
+ effortRows(reasoning).map((row) => (0, import_react16.createElement)(
"button",
{
key: row.effort ?? "default",
@@ -9396,17 +9199,17 @@ function EffortRows({ reasoning, current, onPick }) {
"aria-checked": row.effort === current,
onClick: () => onPick(row.effort)
},
- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, row.label),
- (0, import_react17.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react17.createElement)(CheckIcon) : null)
+ (0, import_react16.createElement)("span", { className: "bt-menu-label" }, row.label),
+ (0, import_react16.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react16.createElement)(CheckIcon) : null)
))
);
}
function ModelRows({ roster, current, onPick }) {
- return groupModels(roster.models).map(([provider, list]) => (0, import_react17.createElement)(
- import_react17.Fragment,
+ return groupModels(roster.models).map(([provider, list]) => (0, import_react16.createElement)(
+ import_react16.Fragment,
{ key: list[0].provider ?? provider },
- (0, import_react17.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
- list.map((model) => (0, import_react17.createElement)(
+ (0, import_react16.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
+ list.map((model) => (0, import_react16.createElement)(
"button",
{
key: model.ref,
@@ -9416,20 +9219,20 @@ function ModelRows({ roster, current, onPick }) {
title: model.ref,
onClick: () => onPick(model.ref)
},
- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, model.name),
- model.ref === roster.defaultModel ? (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
- (0, import_react17.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react17.createElement)(CheckIcon) : null)
+ (0, import_react16.createElement)("span", { className: "bt-menu-label" }, model.name),
+ model.ref === roster.defaultModel ? (0, import_react16.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
+ (0, import_react16.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react16.createElement)(CheckIcon) : null)
))
));
}
-var Caret = () => (0, import_react17.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react17.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
+var Caret = () => (0, import_react16.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react16.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
function useAnchoredMenu() {
- const [place, setPlace] = (0, import_react17.useState)(null);
+ const [place, setPlace] = (0, import_react16.useState)(null);
const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS);
- const button = (0, import_react17.useRef)(null);
- const menu = (0, import_react17.useRef)(null);
+ const button = (0, import_react16.useRef)(null);
+ const menu = (0, import_react16.useRef)(null);
const close = () => setPlace(null);
- (0, import_react17.useEffect)(() => {
+ (0, import_react16.useEffect)(() => {
if (place === null) return void 0;
const onDown = (event) => {
if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return;
@@ -9492,10 +9295,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo
else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null }));
};
const models = roster.models ?? [];
- return (0, import_react17.createElement)(
- import_react17.Fragment,
+ return (0, import_react16.createElement)(
+ import_react16.Fragment,
null,
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"button",
{
ref: button,
@@ -9508,38 +9311,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo
"data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0,
onClick: toggle
},
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"span",
{ className: "bt-model-copy" },
- (0, import_react17.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
- shown ? (0, import_react17.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
+ (0, import_react16.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
+ shown ? (0, import_react16.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
),
- (0, import_react17.createElement)(Caret)
+ (0, import_react16.createElement)(Caret)
),
- shownPlace ? (0, import_react_dom6.createPortal)((0, import_react17.createElement)(
+ shownPlace ? (0, import_react_dom6.createPortal)((0, import_react16.createElement)(
"div",
{ ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 },
- models.length > 0 ? (0, import_react17.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react17.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
- effortRows(reasoning).length > 0 ? (0, import_react17.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
- (0, import_react17.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react17.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+ models.length > 0 ? (0, import_react16.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react16.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
+ effortRows(reasoning).length > 0 ? (0, import_react16.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
+ (0, import_react16.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react16.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
close();
openHarnessModels();
- } }, (0, import_react17.createElement)(SlidersIcon), (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
+ } }, (0, import_react16.createElement)(SlidersIcon), (0, import_react16.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
), document.body) : null
);
}
function ModelChoiceDock({ sessionId, useRoster, actions }) {
const roster = useRoster((value) => value);
const bot = roster.byId[sessionId];
- const [busy, setBusy] = (0, import_react17.useState)("");
- const [error, setError] = (0, import_react17.useState)("");
+ const [busy, setBusy] = (0, import_react16.useState)("");
+ const [error, setError] = (0, import_react16.useState)("");
const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready;
- (0, import_react17.useEffect)(() => {
+ (0, import_react16.useEffect)(() => {
if (shown) void actions.refreshModels?.();
}, [shown, sessionId]);
if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null;
- if (bot.remote) return bot.remote.gone ? (0, import_react17.createElement)(RemoteGoneDock, { bot }) : null;
+ if (bot.remote) return bot.remote.gone ? (0, import_react16.createElement)(RemoteGoneDock, { bot }) : null;
const models = roster.models ?? [];
const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false;
if (bot.model && (!lost || models.length === 0)) return null;
@@ -9552,12 +9355,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) {
}).finally(() => setBusy(""));
};
const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name });
- const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react17.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react17.createElement)(KeyHelpLink, { key: "key-help" })] : [
- ...groupModels(models).map(([provider, list]) => (0, import_react17.createElement)(
+ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react16.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react16.createElement)(KeyHelpLink, { key: "key-help" })] : [
+ ...groupModels(models).map(([provider, list]) => (0, import_react16.createElement)(
"div",
{ key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider },
- (0, import_react17.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
- (0, import_react17.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react17.createElement)("button", {
+ (0, import_react16.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
+ (0, import_react16.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react16.createElement)("button", {
key: model.ref,
type: "button",
className: "bt-model-chip",
@@ -9566,46 +9369,46 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) {
"aria-label": `${model.name} · ${provider}`,
title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref,
onClick: () => pick2(model.ref)
- }, model.name, busy === model.ref ? (0, import_react17.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
+ }, model.name, busy === model.ref ? (0, import_react16.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
)),
- (0, import_react17.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react17.createElement)(SlidersIcon), t("Manage models"))
+ (0, import_react16.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react16.createElement)(SlidersIcon), t("Manage models"))
];
- return (0, import_react17.createElement)(
+ return (0, import_react16.createElement)(
"div",
{ className: "bt-model-dock" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-astack", "data-lead": "" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-msg" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-msg-line" },
- (0, import_react17.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react17.createElement)("div", { className: "bt-bubble" }, text)
+ (0, import_react16.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react16.createElement)("div", { className: "bt-bubble" }, text)
)
)
),
- (0, import_react17.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
- error ? (0, import_react17.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
+ (0, import_react16.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
+ error ? (0, import_react16.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
);
}
function RemoteGoneDock({ bot }) {
- return (0, import_react17.createElement)(
+ return (0, import_react16.createElement)(
"div",
{ className: "bt-model-dock", "data-flybot": "remote-gone" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-astack", "data-lead": "" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-msg" },
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)(
"div",
{ className: "bt-msg-line" },
- (0, import_react17.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react17.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
+ (0, import_react16.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react16.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
)
)
)
@@ -9620,7 +9423,7 @@ function setupChoices(actions) {
}
var KEY_HELP_URL = "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/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md";
function KeyHelpLink() {
- return (0, import_react17.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
+ return (0, import_react16.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
}
function missingModelKey(message) {
const text = String(message ?? "");
@@ -9644,22 +9447,22 @@ function TurnErrorCell({ node, t: chatT, actions }) {
const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw;
const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [];
const deepseek = missing ? /^deepseek/i.test(missing.route) : false;
- return (0, import_react17.createElement)(
+ return (0, import_react16.createElement)(
"div",
{ className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 },
- (0, import_react17.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
- (0, import_react17.createElement)(
+ (0, import_react16.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
+ (0, import_react16.createElement)(
"div",
{ className: "bt-turn-error-copy" },
- (0, import_react17.createElement)("span", { className: "bt-turn-error-title" }, title),
- (0, import_react17.createElement)("span", { className: "bt-turn-error-message" }, message),
- code ? (0, import_react17.createElement)("code", { className: "bt-turn-error-code" }, code) : null
+ (0, import_react16.createElement)("span", { className: "bt-turn-error-title" }, title),
+ (0, import_react16.createElement)("span", { className: "bt-turn-error-message" }, message),
+ code ? (0, import_react16.createElement)("code", { className: "bt-turn-error-code" }, code) : null
),
- choices.length > 0 ? (0, import_react17.createElement)(
+ choices.length > 0 ? (0, import_react16.createElement)(
"div",
{ className: "bt-turn-error-actions" },
- choices.map(([label, run]) => (0, import_react17.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
- deepseek ? (0, import_react17.createElement)(KeyHelpLink, { key: "key-help" }) : null
+ choices.map(([label, run]) => (0, import_react16.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
+ deepseek ? (0, import_react16.createElement)(KeyHelpLink, { key: "key-help" }) : null
) : null
);
}
@@ -9667,14 +9470,14 @@ function DeepSeekSignIn({ useUi, actions }) {
const open = useUi((state) => state.signIn);
const entry = open ? actions.signInEntry?.() : null;
if (!entry) return null;
- return (0, import_react17.createElement)(SignInHost, { key: open, entry, actions });
+ return (0, import_react16.createElement)(SignInHost, { key: open, entry, actions });
}
function SignInHost({ entry, actions }) {
- const [ops] = (0, import_react17.useState)(() => entry.inject?.() ?? {});
+ const [ops] = (0, import_react16.useState)(() => entry.inject?.() ?? {});
const close = () => actions.closeSignIn();
- const useHook = (source) => (selector) => (0, import_react17.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
- const [hooks] = (0, import_react17.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
- return (0, import_react17.createElement)(entry.component, {
+ const useHook = (source) => (selector) => (0, import_react16.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
+ const [hooks] = (0, import_react16.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
+ return (0, import_react16.createElement)(entry.component, {
...ops,
...hooks,
t: actions.accountT(),
@@ -9733,7 +9536,7 @@ var MODEL_CSS = `
`;
// src/client/model-test.js
-var import_react18 = require("react");
+var import_react17 = require("react");
var FAILURE_COPY = {
NO_MODEL: "Pick a model first.",
NO_KEY: "This model has no API key yet. Set one up in the model settings.",
@@ -9758,12 +9561,12 @@ function testOutcome(result, modelName) {
}
var testModel = (id) => call("test-model", { id });
function ModelTestRow({ bot, modelName, test = testModel }) {
- const [state, setState] = (0, import_react18.useState)({ busy: false, outcome: void 0 });
- const live = (0, import_react18.useRef)(true);
- (0, import_react18.useEffect)(() => () => {
+ const [state, setState] = (0, import_react17.useState)({ busy: false, outcome: void 0 });
+ const live = (0, import_react17.useRef)(true);
+ (0, import_react17.useEffect)(() => () => {
live.current = false;
}, []);
- (0, import_react18.useEffect)(() => {
+ (0, import_react17.useEffect)(() => {
setState({ busy: false, outcome: void 0 });
}, [bot.id, bot.model]);
const start = () => {
@@ -9777,21 +9580,21 @@ function ModelTestRow({ bot, modelName, test = testModel }) {
});
};
const { busy, outcome } = state;
- return (0, import_react18.createElement)(
+ return (0, import_react17.createElement)(
"div",
{ className: "bt-set-row bt-bs-row bt-model-test" },
- (0, import_react18.createElement)(
+ (0, import_react17.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
- (0, import_react18.createElement)(
+ (0, import_react17.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
+ (0, import_react17.createElement)(
"span",
{ className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) },
busy ? t("Testing…") : outcome === void 0 ? null : outcome.text,
- outcome?.code ? (0, import_react18.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
+ outcome?.code ? (0, import_react17.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
)
),
- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
+ (0, import_react17.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
);
}
var MODEL_TEST_CSS = `
@@ -9803,17 +9606,17 @@ var MODEL_TEST_CSS = `
`;
// src/client/folder-menu.js
-var import_react19 = require("react");
+var import_react18 = require("react");
var import_react_dom7 = require("react-dom");
var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path);
-var Tick = ({ on }) => (0, import_react19.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react19.createElement)(CheckIcon) : null);
+var Tick = ({ on }) => (0, import_react18.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react18.createElement)(CheckIcon) : null);
function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) {
const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
- const [info, setInfo] = (0, import_react19.useState)(null);
- const [view, setView] = (0, import_react19.useState)("list");
- const [draft, setDraft] = (0, import_react19.useState)("");
- const [listing, setListing] = (0, import_react19.useState)(null);
- const [problem, setProblem] = (0, import_react19.useState)("");
+ const [info, setInfo] = (0, import_react18.useState)(null);
+ const [view, setView] = (0, import_react18.useState)("list");
+ const [draft, setDraft] = (0, import_react18.useState)("");
+ const [listing, setListing] = (0, import_react18.useState)(null);
+ const [problem, setProblem] = (0, import_react18.useState)("");
const fallback = group ? t("Each Bot's own folder") : t("Team workspace");
const toggle = () => {
if (isOpen) {
@@ -9846,35 +9649,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
};
const known = info?.folders ?? [];
const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known;
- const listView = (0, import_react19.createElement)(
+ const listView = (0, import_react18.createElement)(
"div",
{ key: "list", className: "bt-folder-view" },
- (0, import_react19.createElement)(
+ (0, import_react18.createElement)(
"button",
{ type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") },
- (0, import_react19.createElement)("span", { className: "bt-menu-label" }, fallback),
- (0, import_react19.createElement)(Tick, { on: !value })
+ (0, import_react18.createElement)("span", { className: "bt-menu-label" }, fallback),
+ (0, import_react18.createElement)(Tick, { on: !value })
),
- folders.length > 0 ? (0, import_react19.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
- folders.map((folder) => (0, import_react19.createElement)(
+ folders.length > 0 ? (0, import_react18.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
+ folders.map((folder) => (0, import_react18.createElement)(
"button",
{ key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) },
- (0, import_react19.createElement)("span", { className: "bt-folder-copy" }, (0, import_react19.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react19.createElement)("span", { className: "bt-folder-path" }, folder.path)),
- (0, import_react19.createElement)(Tick, { on: folder.path === value })
+ (0, import_react18.createElement)("span", { className: "bt-folder-copy" }, (0, import_react18.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react18.createElement)("span", { className: "bt-folder-path" }, folder.path)),
+ (0, import_react18.createElement)(Tick, { on: folder.path === value })
)),
- (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- info?.picker === "native" ? (0, import_react19.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react19.createElement)(FolderIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
- info?.picker === "browse" ? (0, import_react19.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react19.createElement)(FolderIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
- (0, import_react19.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+ (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ info?.picker === "native" ? (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react18.createElement)(FolderIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
+ info?.picker === "browse" ? (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react18.createElement)(FolderIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
+ (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
setDraft(value ?? "");
setView("type");
- } }, (0, import_react19.createElement)(PencilIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
+ } }, (0, import_react18.createElement)(PencilIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
);
- const typeView = (0, import_react19.createElement)(
+ const typeView = (0, import_react18.createElement)(
"div",
{ key: "type", className: "bt-folder-view" },
- (0, import_react19.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react19.createElement)(ChevronLeftIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
- (0, import_react19.createElement)("input", {
+ (0, import_react18.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react18.createElement)(ChevronLeftIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+ (0, import_react18.createElement)("input", {
className: "bt-folder-input",
autoFocus: true,
value: draft,
@@ -9886,33 +9689,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
}
}),
- (0, import_react19.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
+ (0, import_react18.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
);
const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
- const browseView = listing ? (0, import_react19.createElement)(
+ const browseView = listing ? (0, import_react18.createElement)(
"div",
{ key: `browse:${listing.path}`, className: "bt-folder-view" },
- (0, import_react19.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react19.createElement)(ChevronLeftIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
- (0, import_react19.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
- parent ? (0, import_react19.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react19.createElement)(FolderIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
- inside.map((entry) => (0, import_react19.createElement)(
+ (0, import_react18.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react18.createElement)(ChevronLeftIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+ (0, import_react18.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
+ parent ? (0, import_react18.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react18.createElement)(FolderIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
+ inside.map((entry) => (0, import_react18.createElement)(
"button",
{ key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
- (0, import_react19.createElement)(FolderIcon),
- (0, import_react19.createElement)("span", { className: "bt-menu-label" }, entry.name),
- (0, import_react19.createElement)(ChevronRightIcon)
+ (0, import_react18.createElement)(FolderIcon),
+ (0, import_react18.createElement)("span", { className: "bt-menu-label" }, entry.name),
+ (0, import_react18.createElement)(ChevronRightIcon)
)),
- inside.length === 0 ? (0, import_react19.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
- listing.truncated ? (0, import_react19.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
- (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react19.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
+ inside.length === 0 ? (0, import_react18.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
+ listing.truncated ? (0, import_react18.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
+ (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react18.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
) : null;
const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
- return (0, import_react19.createElement)(
- import_react19.Fragment,
+ return (0, import_react18.createElement)(
+ import_react18.Fragment,
null,
- (0, import_react19.createElement)(
+ (0, import_react18.createElement)(
"button",
{
ref: button,
@@ -9925,19 +9728,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false }
title: value,
onClick: toggle
},
- (0, import_react19.createElement)(
+ (0, import_react18.createElement)(
"span",
{ className: "bt-model-copy" },
- (0, import_react19.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
- value ? (0, import_react19.createElement)("span", { className: "bt-model-provider" }, value) : null
+ (0, import_react18.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
+ value ? (0, import_react18.createElement)("span", { className: "bt-model-provider" }, value) : null
),
- (0, import_react19.createElement)(Caret)
+ (0, import_react18.createElement)(Caret)
),
- shownPlace ? (0, import_react_dom7.createPortal)((0, import_react19.createElement)(
+ shownPlace ? (0, import_react_dom7.createPortal)((0, import_react18.createElement)(
"div",
{ ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
shownView,
- problem ? (0, import_react19.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
+ problem ? (0, import_react18.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
), document.body) : null
);
}
@@ -9957,7 +9760,7 @@ var FOLDER_CSS = `
`;
// src/client/schedules.js
-var import_react20 = require("react");
+var import_react19 = require("react");
var POLL_MS3 = 3e4;
var shared = null;
var listeners = /* @__PURE__ */ new Set();
@@ -9966,8 +9769,8 @@ function publish(view) {
for (const listener of listeners) listener(view);
}
function useSchedules(actions) {
- const [view, setView] = (0, import_react20.useState)(shared);
- (0, import_react20.useEffect)(() => {
+ const [view, setView] = (0, import_react19.useState)(shared);
+ (0, import_react19.useEffect)(() => {
listeners.add(setView);
let stopped = false;
let timer;
@@ -10039,9 +9842,9 @@ function lastRunText(run) {
}
}
function TaskRow({ task, actions, onView, onEdit, showBot, roster }) {
- const [confirming, setConfirming] = (0, import_react20.useState)(false);
- const [busy, setBusy] = (0, import_react20.useState)(false);
- (0, import_react20.useEffect)(() => {
+ const [confirming, setConfirming] = (0, import_react19.useState)(false);
+ const [busy, setBusy] = (0, import_react19.useState)(false);
+ (0, import_react19.useEffect)(() => {
if (!confirming) return void 0;
const timer = setTimeout(() => setConfirming(false), 3e3);
return () => clearTimeout(timer);
@@ -10062,7 +9865,7 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) {
onView(next);
}, () => setBusy(false));
};
- const [retryError, setRetryError] = (0, import_react20.useState)("");
+ const [retryError, setRetryError] = (0, import_react19.useState)("");
const retry = () => {
setBusy(true);
setRetryError("");
@@ -10078,24 +9881,24 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) {
const stoppable = task.status === "active" || task.status === "paused";
const failed = task.lastRun?.result === "failed";
const last = lastRunText(task.lastRun);
- return (0, import_react20.createElement)(
+ return (0, import_react19.createElement)(
"div",
{ className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 },
- showBot ? (0, import_react20.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
- (0, import_react20.createElement)(
+ showBot ? (0, import_react19.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
+ (0, import_react19.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react20.createElement)("span", { className: "bt-task-title" }, task.title),
- (0, import_react20.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
- task.prompt && task.prompt !== task.title ? (0, import_react20.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
- last ? (0, import_react20.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
- retryError ? (0, import_react20.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
+ (0, import_react19.createElement)("span", { className: "bt-task-title" }, task.title),
+ (0, import_react19.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
+ task.prompt && task.prompt !== task.title ? (0, import_react19.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
+ last ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
+ retryError ? (0, import_react19.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
),
- (0, import_react20.createElement)("span", { className: "bt-task-next" }, nextText(task)),
- failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react20.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
- onEdit ? (0, import_react20.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react20.createElement)(PencilIcon)) : null,
- stoppable ? (0, import_react20.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
- (0, import_react20.createElement)("button", {
+ (0, import_react19.createElement)("span", { className: "bt-task-next" }, nextText(task)),
+ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
+ onEdit ? (0, import_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react19.createElement)(PencilIcon)) : null,
+ stoppable ? (0, import_react19.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
+ (0, import_react19.createElement)("button", {
type: "button",
className: "bt-mini bt-task-remove",
"data-confirm": confirming || void 0,
@@ -10103,7 +9906,7 @@ function TaskRow({ task, actions, onView, onEdit, showBot, roster }) {
"aria-label": confirming ? t("Click again to delete") : t("Delete task"),
title: confirming ? void 0 : t("Delete task"),
onClick: remove
- }, confirming ? t("Delete") : (0, import_react20.createElement)(TrashIcon))
+ }, confirming ? t("Delete") : (0, import_react19.createElement)(TrashIcon))
);
}
var pad = (number) => String(number).padStart(2, "0");
@@ -10136,17 +9939,17 @@ function formStart(task) {
}
function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
const first = formStart(task);
- const [target, setTarget] = (0, import_react20.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? "");
- const [title, setTitle] = (0, import_react20.useState)(first.title);
- const [prompt, setPrompt] = (0, import_react20.useState)(first.prompt);
- const [kind, setKind] = (0, import_react20.useState)(first.kind);
- const [at, setAt] = (0, import_react20.useState)(first.at);
- const [time, setTime] = (0, import_react20.useState)(first.time);
- const [weekdays, setWeekdays] = (0, import_react20.useState)(first.weekdays);
- const [count, setCount] = (0, import_react20.useState)(first.count);
- const [unit, setUnit] = (0, import_react20.useState)(first.unit);
- const [busy, setBusy] = (0, import_react20.useState)(false);
- const [error, setError] = (0, import_react20.useState)("");
+ const [target, setTarget] = (0, import_react19.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? "");
+ const [title, setTitle] = (0, import_react19.useState)(first.title);
+ const [prompt, setPrompt] = (0, import_react19.useState)(first.prompt);
+ const [kind, setKind] = (0, import_react19.useState)(first.kind);
+ const [at, setAt] = (0, import_react19.useState)(first.at);
+ const [time, setTime] = (0, import_react19.useState)(first.time);
+ const [weekdays, setWeekdays] = (0, import_react19.useState)(first.weekdays);
+ const [count, setCount] = (0, import_react19.useState)(first.count);
+ const [unit, setUnit] = (0, import_react19.useState)(first.unit);
+ const [busy, setBusy] = (0, import_react19.useState)(false);
+ const [error, setError] = (0, import_react19.useState)("");
const timing = () => {
if (kind === "cron") return { kind: "keep" };
if (kind === "at") return { kind, at: new Date(at).toISOString() };
@@ -10173,69 +9976,69 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
});
};
const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
- return (0, import_react20.createElement)(
+ return (0, import_react19.createElement)(
"form",
{ className: "bt-set-card bt-task-form", onSubmit: submit },
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)(
"label",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)("span", { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
+ (0, import_react19.createElement)(
"select",
{ className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
- (0, import_react20.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react20.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
- roster.rooms.length > 0 ? (0, import_react20.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react20.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
+ (0, import_react19.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react19.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
+ roster.rooms.length > 0 ? (0, import_react19.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react19.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
)
),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)(
"label",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
- (0, import_react20.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
+ (0, import_react19.createElement)("span", { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+ (0, import_react19.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)(
"label",
{ className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
- (0, import_react20.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
+ (0, import_react19.createElement)("span", { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
+ (0, import_react19.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)(
"div",
{ className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("When"))),
- (0, import_react20.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react20.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)("span", { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-bs-label" }, t("When"))),
+ (0, import_react19.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react19.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
+ (0, import_react19.createElement)(
"div",
{ key: kind, className: "bt-task-when bt-veil" },
- kind === "cron" ? (0, import_react20.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
- kind === "at" ? (0, import_react20.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
- kind === "weekly" ? (0, import_react20.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react20.createElement)("button", {
+ kind === "cron" ? (0, import_react19.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
+ kind === "at" ? (0, import_react19.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
+ kind === "weekly" ? (0, import_react19.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react19.createElement)("button", {
key: day,
type: "button",
className: "bt-task-day",
"aria-pressed": weekdays.includes(day),
onClick: () => toggleDay(day)
}, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
- kind === "daily" || kind === "weekly" ? (0, import_react20.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
- kind === "every" ? (0, import_react20.createElement)(
- import_react20.Fragment,
+ kind === "daily" || kind === "weekly" ? (0, import_react19.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
+ kind === "every" ? (0, import_react19.createElement)(
+ import_react19.Fragment,
null,
- (0, import_react20.createElement)("span", { className: "bt-task-every" }, t("Every")),
- (0, import_react20.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)("span", { className: "bt-task-every" }, t("Every")),
+ (0, import_react19.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
+ (0, import_react19.createElement)(
"select",
{ className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
- UNITS.map(([id, , label]) => (0, import_react20.createElement)("option", { key: id, value: id }, t(label)))
+ UNITS.map(([id, , label]) => (0, import_react19.createElement)("option", { key: id, value: id }, t(label)))
)
) : null
)
),
- error ? (0, import_react20.createElement)("div", { className: "bt-set-row" }, (0, import_react20.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
- (0, import_react20.createElement)(
+ error ? (0, import_react19.createElement)("div", { className: "bt-set-row" }, (0, import_react19.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+ (0, import_react19.createElement)(
"div",
{ className: "bt-set-row bt-task-foot" },
- (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+ (0, import_react19.createElement)(
"button",
{ type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" },
task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task")
@@ -10248,21 +10051,21 @@ function openPlugins(actions) {
actions.selectPanel("plugins");
}
function AutomationOff({ actions }) {
- return (0, import_react20.createElement)(
+ return (0, import_react19.createElement)(
"div",
{ className: "bt-set-card bt-task-off" },
- (0, import_react20.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react20.createElement)(ClockIcon)),
- (0, import_react20.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
- (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
- (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+ (0, import_react19.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react19.createElement)(ClockIcon)),
+ (0, import_react19.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
+ (0, import_react19.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
);
}
function SchedulesPage({ roster, actions, start }) {
const view = useSchedules(actions);
- const [adding, setAdding] = (0, import_react20.useState)(Boolean(start));
- const [editing, setEditing] = (0, import_react20.useState)(null);
- if (view === null) return (0, import_react20.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
- if (!view.available) return (0, import_react20.createElement)(AutomationOff, { actions });
+ const [adding, setAdding] = (0, import_react19.useState)(Boolean(start));
+ const [editing, setEditing] = (0, import_react19.useState)(null);
+ if (view === null) return (0, import_react19.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
+ if (!view.available) return (0, import_react19.createElement)(AutomationOff, { actions });
const shown = (next) => {
publish(next);
setAdding(false);
@@ -10273,51 +10076,51 @@ function SchedulesPage({ roster, actions, start }) {
setEditing(task);
};
const byChat = [
- ...roster.bots.map((bot) => [bot.id, (0, import_react20.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
- ...roster.rooms.map((room2) => [room2.id, (0, import_react20.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
+ ...roster.bots.map((bot) => [bot.id, (0, import_react19.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
+ ...roster.rooms.map((room2) => [room2.id, (0, import_react19.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
].filter(([, , , tasks]) => tasks.length > 0);
return [
- adding ? (0, import_react20.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react20.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
+ adding ? (0, import_react19.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react19.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
setEditing(null);
setAdding(true);
- } }, (0, import_react20.createElement)(PlusIcon), t("New task"))),
- byChat.length === 0 && !adding ? (0, import_react20.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react20.createElement)("div", { className: "bt-set-row" }, (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react20.createElement)(
+ } }, (0, import_react19.createElement)(PlusIcon), t("New task"))),
+ byChat.length === 0 && !adding ? (0, import_react19.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react19.createElement)("div", { className: "bt-set-row" }, (0, import_react19.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react19.createElement)(
"section",
{ key: id, className: "bt-set-section" },
- (0, import_react20.createElement)("h3", { className: "bt-task-head" }, avatar, name),
- (0, import_react20.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
+ (0, import_react19.createElement)("h3", { className: "bt-task-head" }, avatar, name),
+ (0, import_react19.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
))
];
}
function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) {
const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0;
- const rows = tasks.filter((task) => task !== open).map((task) => (0, import_react20.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit }));
- return (0, import_react20.createElement)(
- import_react20.Fragment,
+ const rows = tasks.filter((task) => task !== open).map((task) => (0, import_react19.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit }));
+ return (0, import_react19.createElement)(
+ import_react19.Fragment,
null,
- open ? (0, import_react20.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
- rows.length + extra.length > 0 ? (0, import_react20.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
+ open ? (0, import_react19.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
+ rows.length + extra.length > 0 ? (0, import_react19.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
);
}
function BotSchedules({ bot, roster, actions }) {
const view = useSchedules(actions);
- const [editing, setEditing] = (0, import_react20.useState)(null);
+ const [editing, setEditing] = (0, import_react19.useState)(null);
if (view === null) return null;
const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
- return (0, import_react20.createElement)(
+ return (0, import_react19.createElement)(
"section",
{ className: "bt-set-section" },
- (0, import_react20.createElement)("h3", null, t("Scheduled tasks")),
- !view.available ? (0, import_react20.createElement)(
+ (0, import_react19.createElement)("h3", null, t("Scheduled tasks")),
+ !view.available ? (0, import_react19.createElement)(
"div",
{ className: "bt-set-card" },
- (0, import_react20.createElement)(
+ (0, import_react19.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
- (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+ (0, import_react19.createElement)("span", { className: "bt-set-copy" }, (0, import_react19.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
)
- ) : (0, import_react20.createElement)(TaskList, {
+ ) : (0, import_react19.createElement)(TaskList, {
tasks,
editing,
roster,
@@ -10328,23 +10131,23 @@ function BotSchedules({ bot, roster, actions }) {
setEditing(null);
},
onCancel: () => setEditing(null),
- extra: [(0, import_react20.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react20.createElement)(PlusIcon), t("New task"))]
+ extra: [(0, import_react19.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react19.createElement)(PlusIcon), t("New task"))]
})
);
}
function ScheduleSummary({ bot, actions }) {
const view = useSchedules(actions);
const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
- return (0, import_react20.createElement)(
+ return (0, import_react19.createElement)(
"div",
null,
- (0, import_react20.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
- tasks.length === 0 ? (0, import_react20.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react20.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react20.createElement)(
+ (0, import_react19.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
+ tasks.length === 0 ? (0, import_react19.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react19.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react19.createElement)(
"button",
{ key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") },
- (0, import_react20.createElement)(ClockIcon),
- (0, import_react20.createElement)("span", { className: "bt-task-line-title" }, task.title),
- (0, import_react20.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
+ (0, import_react19.createElement)(ClockIcon),
+ (0, import_react19.createElement)("span", { className: "bt-task-line-title" }, task.title),
+ (0, import_react19.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
)))
);
}
@@ -10394,7 +10197,7 @@ var SCHEDULE_CSS = `
`;
// src/client/capability-settings.js
-var import_react21 = require("react");
+var import_react20 = require("react");
var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] });
var parseMap = (text, label) => {
let value;
@@ -10408,11 +10211,11 @@ var parseMap = (text, label) => {
};
var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…");
function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) {
- return (0, import_react21.createElement)(
+ return (0, import_react20.createElement)(
"label",
{ className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t(label))),
- (0, import_react21.createElement)(multiline ? "textarea" : "input", {
+ (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t(label))),
+ (0, import_react20.createElement)(multiline ? "textarea" : "input", {
className: multiline ? "bt-input bt-input-area" : "bt-input",
value,
disabled,
@@ -10424,7 +10227,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl
);
}
function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
- const [error, setError] = (0, import_react21.useState)("");
+ const [error, setError] = (0, import_react20.useState)("");
const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
const submit = async (event) => {
event.preventDefault();
@@ -10455,95 +10258,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
}
};
const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
- return (0, import_react21.createElement)(
+ return (0, import_react20.createElement)(
"form",
{ className: "bt-connector-form", onSubmit: submit },
- (0, import_react21.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
+ (0, import_react20.createElement)(
"label",
{ className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
+ (0, import_react20.createElement)(
"select",
{ className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
- ["mcp", "skill", "pack"].map((kind) => (0, import_react21.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
+ ["mcp", "skill", "pack"].map((kind) => (0, import_react20.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
)
),
- (0, import_react21.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
- draft.kind === "mcp" ? (0, import_react21.createElement)(
+ (0, import_react20.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
+ draft.kind === "mcp" ? (0, import_react20.createElement)(
"label",
{ className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
+ (0, import_react20.createElement)(
"select",
{ className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) },
- (0, import_react21.createElement)("option", { value: "stdio" }, t("Local command")),
- (0, import_react21.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
+ (0, import_react20.createElement)("option", { value: "stdio" }, t("Local command")),
+ (0, import_react20.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
)
) : null,
- draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react21.createElement)(
+ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react20.createElement)(
"div",
null,
- (0, import_react21.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
- (0, import_react21.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
- (0, import_react21.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+ (0, import_react20.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
+ (0, import_react20.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
+ (0, import_react20.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
) : null,
- draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react21.createElement)(
+ draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react20.createElement)(
"div",
null,
- (0, import_react21.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
- (0, import_react21.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+ (0, import_react20.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
+ (0, import_react20.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
) : null,
- draft.kind === "skill" ? (0, import_react21.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
- draft.kind === "pack" ? (0, import_react21.createElement)(
+ draft.kind === "skill" ? (0, import_react20.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
+ draft.kind === "pack" ? (0, import_react20.createElement)(
"div",
{ className: "bt-set-card" },
- available.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react21.createElement)(
+ available.length === 0 ? (0, import_react20.createElement)("div", { className: "bt-set-row" }, (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react20.createElement)(
"label",
{ key: item.name, className: "bt-set-row bt-bs-row" },
- (0, import_react21.createElement)("span", { className: "bt-set-copy" }, (0, import_react21.createElement)("span", { className: "bt-bs-label" }, item.name)),
- (0, import_react21.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
+ (0, import_react20.createElement)("span", { className: "bt-set-copy" }, (0, import_react20.createElement)("span", { className: "bt-bs-label" }, item.name)),
+ (0, import_react20.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
))
) : null,
- error ? (0, import_react21.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
- (0, import_react21.createElement)(
+ error ? (0, import_react20.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
+ (0, import_react20.createElement)(
"div",
{ className: "bt-connector-form-row" },
- (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
- (0, import_react21.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
+ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
+ (0, import_react20.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
)
);
}
function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
- return (0, import_react21.createElement)(
+ return (0, import_react20.createElement)(
"div",
{ className: "bt-connector" },
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)(
"div",
{ className: "bt-connector-head" },
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)(
"span",
{ className: "bt-connector-text" },
- (0, import_react21.createElement)("span", { className: "bt-connector-name" }, item.name),
- (0, import_react21.createElement)("span", { className: "bt-connector-status" }, secondary)
+ (0, import_react20.createElement)("span", { className: "bt-connector-name" }, item.name),
+ (0, import_react20.createElement)("span", { className: "bt-connector-status" }, secondary)
),
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)(
"span",
{ className: "bt-connector-actions" },
- item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
- (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
- (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
+ item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
+ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
+ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
)
)
);
}
function CapabilitiesPage({ roster, actions, run }) {
const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name));
- const [draft, setDraft] = (0, import_react21.useState)(null);
- const [confirming, setConfirming] = (0, import_react21.useState)("");
- (0, import_react21.useEffect)(() => {
+ const [draft, setDraft] = (0, import_react20.useState)(null);
+ const [confirming, setConfirming] = (0, import_react20.useState)("");
+ (0, import_react20.useEffect)(() => {
if (!confirming) return void 0;
const timer = setTimeout(() => setConfirming(""), 4e3);
return () => clearTimeout(timer);
@@ -10566,20 +10369,20 @@ function CapabilitiesPage({ roster, actions, run }) {
setConfirming("");
void run(actions.deleteCapability(item.name));
};
- return (0, import_react21.createElement)(
+ return (0, import_react20.createElement)(
"section",
{ className: "bt-set-section" },
- (0, import_react21.createElement)("h3", null, t("Shared capabilities")),
- (0, import_react21.createElement)(
+ (0, import_react20.createElement)("h3", null, t("Shared capabilities")),
+ (0, import_react20.createElement)(
"div",
{ className: "bt-actions" },
- (0, import_react21.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
+ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
),
- draft ? (0, import_react21.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
- (0, import_react21.createElement)(
+ draft ? (0, import_react20.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
+ (0, import_react20.createElement)(
"div",
{ className: "bt-set-card bt-connectors-card" },
- items.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-set-row" }, (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react21.createElement)(CapabilityRow, {
+ items.length === 0 ? (0, import_react20.createElement)("div", { className: "bt-set-row" }, (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react20.createElement)(CapabilityRow, {
key: item.name,
item,
confirming: confirming === item.name,
@@ -10595,10 +10398,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow
const attached = new Set(bot.capabilities ?? []);
const statuses = roster.capabilityStatuses?.[bot.id] ?? {};
if (items.length === 0) return null;
- return (0, import_react21.createElement)(
+ return (0, import_react20.createElement)(
Card2,
{ title: t("Capabilities") },
- items.map((item) => (0, import_react21.createElement)(SwitchRow2, {
+ items.map((item) => (0, import_react20.createElement)(SwitchRow2, {
key: item.name,
label: item.name,
hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0,
@@ -10609,7 +10412,7 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow
}
// src/client/connector-bots.js
-var import_react22 = require("react");
+var import_react21 = require("react");
function ownBots(roster) {
const me = roster?.self?.memberId;
return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me));
@@ -10625,37 +10428,37 @@ function usageText(roster, id) {
return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length });
}
function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) {
- return (0, import_react22.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
+ return (0, import_react21.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
}
function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) {
const bots = ownBots(roster);
- return (0, import_react22.createElement)(
+ return (0, import_react21.createElement)(
"div",
{ className: "bt-connector-bots" },
- bots.length === 0 ? (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react22.createElement)(
+ bots.length === 0 ? (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react21.createElement)(
"div",
{ key: bot.id, className: "bt-connector-bot" },
- (0, import_react22.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
+ (0, import_react21.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
)),
- (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
+ (0, import_react21.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
);
}
function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
const connected = connectedConnectors(roster);
if (connected.length === 0) return null;
- return (0, import_react22.createElement)(
+ return (0, import_react21.createElement)(
Card2,
{ title: t("Connectors") },
- connected.map((connector) => (0, import_react22.createElement)(
+ connected.map((connector) => (0, import_react21.createElement)(
"div",
{ key: connector.id, className: "bt-connector-bot" },
- (0, import_react22.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
+ (0, import_react21.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
))
);
}
// src/client/memory-panel.js
-var import_react23 = require("react");
+var import_react22 = require("react");
var POLL_MS4 = 1500;
var MENU_EXIT_MS2 = 120;
var counts = /* @__PURE__ */ new Map();
@@ -10666,8 +10469,8 @@ function noteCount(botId, count) {
for (const listener of countListeners) listener();
}
function useMemoryCount(botId, actions) {
- const [count, setCount] = (0, import_react23.useState)(counts.get(botId) ?? null);
- (0, import_react23.useEffect)(() => {
+ const [count, setCount] = (0, import_react22.useState)(counts.get(botId) ?? null);
+ (0, import_react22.useEffect)(() => {
const listener = () => setCount(counts.get(botId) ?? null);
countListeners.add(listener);
listener();
@@ -10686,16 +10489,16 @@ function useMemoryCount(botId, actions) {
function MemoryRow({ bot, actions }) {
const count = useMemoryCount(bot.id, actions);
const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
null,
- (0, import_react23.createElement)("div", { className: "bt-section-title" }, t("Memory")),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)("div", { className: "bt-section-title" }, t("Memory")),
+ (0, import_react22.createElement)(
"button",
{ type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
- (0, import_react23.createElement)(MemoryIcon),
- (0, import_react23.createElement)("span", { className: "bt-mem-row-copy" }, label),
- (0, import_react23.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react23.createElement)(ChevronRightIcon))
+ (0, import_react22.createElement)(MemoryIcon),
+ (0, import_react22.createElement)("span", { className: "bt-mem-row-copy" }, label),
+ (0, import_react22.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react22.createElement)(ChevronRightIcon))
)
);
}
@@ -10710,45 +10513,45 @@ function ago(at, now) {
return format.format(-Math.round(hours / 24), "day");
}
function Skeleton({ label }) {
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
{ className: "bt-mem-skel", "aria-busy": true },
- [0, 1].map((block) => (0, import_react23.createElement)(
+ [0, 1].map((block) => (0, import_react22.createElement)(
"div",
{ key: block, className: "bt-mem-skel-block" },
- (0, import_react23.createElement)("span", { className: "bt-mem-skel-head" }),
- (0, import_react23.createElement)("span", null),
- (0, import_react23.createElement)("span", null),
- (0, import_react23.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
+ (0, import_react22.createElement)("span", { className: "bt-mem-skel-head" }),
+ (0, import_react22.createElement)("span", null),
+ (0, import_react22.createElement)("span", null),
+ (0, import_react22.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
)),
- label ? (0, import_react23.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
+ label ? (0, import_react22.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
);
}
function Summary({ bot, view, regenerate }) {
if (view.entries.length === 0) {
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
{ className: "bt-mem-empty" },
- (0, import_react23.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react23.createElement)(MemoryIcon)),
- (0, import_react23.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
- (0, import_react23.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
+ (0, import_react22.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react22.createElement)(MemoryIcon)),
+ (0, import_react22.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
+ (0, import_react22.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
);
}
- const failed = view.error && !view.summarizing ? (0, import_react23.createElement)(
+ const failed = view.error && !view.summarizing ? (0, import_react22.createElement)(
"div",
{ className: "bt-mem-failed", role: "alert" },
- (0, import_react23.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
- (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
+ (0, import_react22.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
+ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
) : null;
- if (!view.summary) return (0, import_react23.createElement)(import_react23.Fragment, null, failed, failed ? null : (0, import_react23.createElement)(Skeleton, { label: t("Writing the summary…") }));
- return (0, import_react23.createElement)(
- import_react23.Fragment,
+ if (!view.summary) return (0, import_react22.createElement)(import_react22.Fragment, null, failed, failed ? null : (0, import_react22.createElement)(Skeleton, { label: t("Writing the summary…") }));
+ return (0, import_react22.createElement)(
+ import_react22.Fragment,
null,
failed,
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
- summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react23.createElement)("h3", { key: index }, block.text) : (0, import_react23.createElement)("p", { key: index }, block.text))
+ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react22.createElement)("h3", { key: index }, block.text) : (0, import_react22.createElement)("p", { key: index }, block.text))
)
);
}
@@ -10760,9 +10563,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
return groups;
}, []);
function EntryRow({ entry, remove }) {
- const [confirming, setConfirming] = (0, import_react23.useState)(false);
- const [busy, setBusy] = (0, import_react23.useState)(false);
- (0, import_react23.useEffect)(() => {
+ const [confirming, setConfirming] = (0, import_react22.useState)(false);
+ const [busy, setBusy] = (0, import_react22.useState)(false);
+ (0, import_react22.useEffect)(() => {
if (!confirming) return void 0;
const timer = setTimeout(() => setConfirming(false), 3e3);
return () => clearTimeout(timer);
@@ -10777,16 +10580,16 @@ function EntryRow({ entry, remove }) {
setBusy(true);
if (!await remove(entry)) setBusy(false);
};
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"li",
{ className: "bt-mem-entry", "data-busy": busy || void 0 },
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-entry-copy" },
- (0, import_react23.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
- meta ? (0, import_react23.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
+ (0, import_react22.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
+ meta ? (0, import_react22.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
),
- (0, import_react23.createElement)("button", {
+ (0, import_react22.createElement)("button", {
type: "button",
className: "bt-mem-remove",
"data-confirm": confirming || void 0,
@@ -10794,7 +10597,7 @@ function EntryRow({ entry, remove }) {
"aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
title: confirming ? void 0 : t("Remove from memory"),
onClick: click
- }, confirming ? t("Remove") : (0, import_react23.createElement)(TrashIcon))
+ }, confirming ? t("Remove") : (0, import_react22.createElement)(TrashIcon))
);
}
function Entries({ bot, view, remove }) {
@@ -10803,25 +10606,25 @@ function Entries({ bot, view, remove }) {
{ scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
{ scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
];
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
{ className: "bt-mem-entries" },
- sections.map((section) => (0, import_react23.createElement)(
+ sections.map((section) => (0, import_react22.createElement)(
"section",
{ key: section.scope, className: "bt-mem-section" },
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-section-head" },
- (0, import_react23.createElement)("h3", null, section.title),
- section.hint ? (0, import_react23.createElement)("span", null, section.hint) : null,
- (0, import_react23.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
+ (0, import_react22.createElement)("h3", null, section.title),
+ section.hint ? (0, import_react22.createElement)("span", null, section.hint) : null,
+ (0, import_react22.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
),
- groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react23.createElement)(
- import_react23.Fragment,
+ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react22.createElement)(
+ import_react22.Fragment,
{ key: group.key },
- group.topic ? (0, import_react23.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
- (0, import_react23.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react23.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
- )) : (0, import_react23.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
+ group.topic ? (0, import_react22.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
+ (0, import_react22.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react22.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
+ )) : (0, import_react22.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
))
);
}
@@ -10833,40 +10636,40 @@ function changeLine(change) {
return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text });
}
function Reply({ answer, dismiss }) {
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
{ className: "bt-mem-reply", role: "status" },
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-reply-head" },
- (0, import_react23.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
- answer.pending ? null : (0, import_react23.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react23.createElement)(CloseIcon))
+ (0, import_react22.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
+ answer.pending ? null : (0, import_react22.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react22.createElement)(CloseIcon))
),
- answer.pending ? (0, import_react23.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react23.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react23.createElement)(
- import_react23.Fragment,
+ answer.pending ? (0, import_react22.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react22.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react22.createElement)(
+ import_react22.Fragment,
null,
- answer.reply ? (0, import_react23.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
- answer.changes.length > 0 ? (0, import_react23.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react23.createElement)(
+ answer.reply ? (0, import_react22.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
+ answer.changes.length > 0 ? (0, import_react22.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react22.createElement)(
"li",
{ key: index, "data-ok": change.ok || void 0 },
- change.ok ? (0, import_react23.createElement)(CheckIcon) : (0, import_react23.createElement)(CloseIcon),
- (0, import_react23.createElement)("span", null, changeLine(change))
+ change.ok ? (0, import_react22.createElement)(CheckIcon) : (0, import_react22.createElement)(CloseIcon),
+ (0, import_react22.createElement)("span", null, changeLine(change))
))) : null
)
);
}
function useMemoryView(botId, actions) {
- const [view, setView] = (0, import_react23.useState)(null);
- const [error, setError] = (0, import_react23.useState)("");
- const timer = (0, import_react23.useRef)(0);
- const latest = (0, import_react23.useRef)(0);
- const alive = (0, import_react23.useRef)(true);
- const show = (0, import_react23.useCallback)((next) => {
+ const [view, setView] = (0, import_react22.useState)(null);
+ const [error, setError] = (0, import_react22.useState)("");
+ const timer = (0, import_react22.useRef)(0);
+ const latest = (0, import_react22.useRef)(0);
+ const alive = (0, import_react22.useRef)(true);
+ const show = (0, import_react22.useCallback)((next) => {
setView(next);
setError("");
noteCount(botId, next.entries.length);
}, [botId]);
- const load = (0, import_react23.useCallback)(async (flags = {}) => {
+ const load = (0, import_react22.useCallback)(async (flags = {}) => {
clearTimeout(timer.current);
const ticket = ++latest.current;
try {
@@ -10878,7 +10681,7 @@ function useMemoryView(botId, actions) {
if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
}
}, [botId]);
- (0, import_react23.useEffect)(() => {
+ (0, import_react22.useEffect)(() => {
alive.current = true;
void load({ summarize: true });
return () => {
@@ -10889,8 +10692,8 @@ function useMemoryView(botId, actions) {
return { view, error, load, show };
}
function useNow3(every) {
- const [now, setNow] = (0, import_react23.useState)(Date.now());
- (0, import_react23.useEffect)(() => {
+ const [now, setNow] = (0, import_react22.useState)(Date.now());
+ (0, import_react22.useEffect)(() => {
const id = setInterval(() => setNow(Date.now()), every);
return () => clearInterval(id);
}, [every]);
@@ -10898,18 +10701,18 @@ function useNow3(every) {
}
function MemoryDialog({ bot, actions, leaving = false }) {
const { view, error, load, show } = useMemoryView(bot.id, actions);
- const [page, setPage] = (0, import_react23.useState)("summary");
- const [menu, setMenu] = (0, import_react23.useState)(false);
+ const [page, setPage] = (0, import_react22.useState)("summary");
+ const [menu, setMenu] = (0, import_react22.useState)(false);
const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2);
- const [text, setText] = (0, import_react23.useState)("");
- const [answer, setAnswer] = (0, import_react23.useState)(null);
- const menuRef = (0, import_react23.useRef)(null);
- const dotsRef = (0, import_react23.useRef)(null);
- const inputRef = (0, import_react23.useRef)(null);
+ const [text, setText] = (0, import_react22.useState)("");
+ const [answer, setAnswer] = (0, import_react22.useState)(null);
+ const menuRef = (0, import_react22.useRef)(null);
+ const dotsRef = (0, import_react22.useRef)(null);
+ const inputRef = (0, import_react22.useRef)(null);
const now = useNow3(3e4);
- const close = (0, import_react23.useCallback)(() => actions.closeMemory(), []);
+ const close = (0, import_react22.useCallback)(() => actions.closeMemory(), []);
useEscape(close);
- (0, import_react23.useEffect)(() => {
+ (0, import_react22.useEffect)(() => {
if (!menu) return void 0;
const onDown = (event) => {
if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return;
@@ -10971,52 +10774,52 @@ function MemoryDialog({ bot, actions, leaving = false }) {
const when3 = view?.summary ? ago(view.summary.at, now) : null;
const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : "";
const busy = answer?.pending === true;
- return (0, import_react23.createElement)(
+ return (0, import_react22.createElement)(
"div",
{ className: "bt-mem-layer", "data-leaving": leaving || void 0 },
- (0, import_react23.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) },
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-head" },
- page === "entries" ? (0, import_react23.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react23.createElement)(ChevronLeftIcon)) : null,
- (0, import_react23.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
- (0, import_react23.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
- (0, import_react23.createElement)("span", { className: "bt-mem-head-gap" }),
- (0, import_react23.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react23.createElement)(DotsIcon)),
- (0, import_react23.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react23.createElement)(CloseIcon)),
- menuShown ? (0, import_react23.createElement)(
+ page === "entries" ? (0, import_react22.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react22.createElement)(ChevronLeftIcon)) : null,
+ (0, import_react22.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
+ (0, import_react22.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
+ (0, import_react22.createElement)("span", { className: "bt-mem-head-gap" }),
+ (0, import_react22.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react22.createElement)(DotsIcon)),
+ (0, import_react22.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react22.createElement)(CloseIcon)),
+ menuShown ? (0, import_react22.createElement)(
"div",
{ ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 },
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"button",
{ type: "button", role: "menuitem", onClick: openEntries },
- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
- (0, import_react23.createElement)("span", { className: "bt-menu-hint" }, String(count))
+ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
+ (0, import_react22.createElement)("span", { className: "bt-menu-hint" }, String(count))
),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"button",
{ type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate },
- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
+ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
)
) : null
),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-body" },
- error ? (0, import_react23.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
- view === null ? error ? null : (0, import_react23.createElement)(Skeleton) : page === "entries" ? (0, import_react23.createElement)(Entries, { bot, view, remove }) : (0, import_react23.createElement)(Summary, { bot, view, regenerate })
+ error ? (0, import_react22.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
+ view === null ? error ? null : (0, import_react22.createElement)(Skeleton) : page === "entries" ? (0, import_react22.createElement)(Entries, { bot, view, remove }) : (0, import_react22.createElement)(Summary, { bot, view, regenerate })
),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"div",
{ className: "bt-mem-foot" },
- answer ? (0, import_react23.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
- (0, import_react23.createElement)(
+ answer ? (0, import_react22.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
+ (0, import_react22.createElement)(
"form",
{ className: "bt-mem-ask", onSubmit: ask },
- (0, import_react23.createElement)("input", {
+ (0, import_react22.createElement)("input", {
ref: inputRef,
className: "bt-mem-input",
value: text,
@@ -11026,10 +10829,10 @@ function MemoryDialog({ bot, actions, leaving = false }) {
"aria-label": t("Ask about or update {name}'s memory", { name: bot.name }),
onChange: (event) => setText(event.target.value)
}),
- (0, import_react23.createElement)(
+ (0, import_react22.createElement)(
"button",
{ type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") },
- busy ? (0, import_react23.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react23.createElement)(ArrowUpIcon)
+ busy ? (0, import_react22.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react22.createElement)(ArrowUpIcon)
)
)
)
@@ -11038,7 +10841,7 @@ function MemoryDialog({ bot, actions, leaving = false }) {
}
// src/client/standing-approvals.js
-var import_react24 = require("react");
+var import_react23 = require("react");
var standingOf = (target, roster) => {
const me = roster?.self?.memberId;
return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me);
@@ -11047,19 +10850,19 @@ function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card
const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? [];
if (grants.length === 0) return null;
const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking.");
- return (0, import_react24.createElement)(
+ return (0, import_react23.createElement)(
Card2,
{ title: t("Always allowed") },
- grants.map((grant) => (0, import_react24.createElement)(
+ grants.map((grant) => (0, import_react23.createElement)(
"div",
{ key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind },
- (0, import_react24.createElement)(
+ (0, import_react23.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react24.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint)
+ (0, import_react23.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
+ (0, import_react23.createElement)("span", { className: "bt-set-hint" }, hint)
),
- (0, import_react24.createElement)("button", {
+ (0, import_react23.createElement)("button", {
type: "button",
className: "bt-soft bt-soft-sm",
"aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }),
@@ -11070,7 +10873,7 @@ function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card
}
// src/client/bot-settings.js
-var import_react25 = require("react");
+var import_react24 = require("react");
var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
async function avatarImage(file) {
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
@@ -11094,8 +10897,8 @@ async function avatarImage(file) {
throw new Error(t("That image is too large"));
}
function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) {
- const [draft, setDraft] = (0, import_react25.useState)(null);
- const reverting = (0, import_react25.useRef)(false);
+ const [draft, setDraft] = (0, import_react24.useState)(null);
+ const reverting = (0, import_react24.useRef)(false);
const commit = () => {
const next = draft?.trim();
setDraft(null);
@@ -11106,7 +10909,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu
if (next === void 0 || next === (value ?? "") || required && next === "") return;
void onSave(next);
};
- return (0, import_react25.createElement)(multiline ? "textarea" : "input", {
+ return (0, import_react24.createElement)(multiline ? "textarea" : "input", {
className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
value: draft ?? value ?? "",
placeholder,
@@ -11127,29 +10930,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu
});
}
function Row({ label, hint, stack = false, children }) {
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
"label",
{ className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react25.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint) : null),
children
);
}
function Card({ title, children }) {
- return (0, import_react25.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react25.createElement)("h3", null, title) : null, (0, import_react25.createElement)("div", { className: "bt-set-card" }, children));
+ return (0, import_react24.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react24.createElement)("h3", null, title) : null, (0, import_react24.createElement)("div", { className: "bt-set-card" }, children));
}
function Switch({ on, label, disabled, onChange }) {
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
"button",
{ type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
- (0, import_react25.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
+ (0, import_react24.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
);
}
function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) {
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react25.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react25.createElement)("span", { className: "bt-set-hint" }, hint) : null),
- (0, import_react25.createElement)(Switch, { on, label, disabled, onChange })
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ (0, import_react24.createElement)(Switch, { on, label, disabled, onChange })
);
}
function modelHint(bot, roster) {
@@ -11160,22 +10963,22 @@ function modelHint(bot, roster) {
return t("Applies from the next message.");
}
function ShapeRing({ shape, size }) {
- const maskId = (0, import_react25.useRef)("");
+ const maskId = (0, import_react24.useRef)("");
if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`;
const def = shapeOf(shape);
const unit = def.size / size;
const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`;
- return (0, import_react25.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
+ return (0, import_react24.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
}
function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))];
if (!shapes.includes(look.shape)) shapes.push(look.shape);
const rows = [];
for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS));
- return (0, import_react25.createElement)(
- import_react25.Fragment,
+ return (0, import_react24.createElement)(
+ import_react24.Fragment,
null,
- (0, import_react25.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react25.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react25.createElement)("button", {
+ (0, import_react24.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react24.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react24.createElement)("button", {
key: id,
type: "button",
className: "bt-shape",
@@ -11183,8 +10986,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
title: t(shapeOf(id).label),
"aria-pressed": !hasPhoto && look.shape === id,
onClick: () => onShape(id)
- }, (0, import_react25.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react25.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
- (0, import_react25.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react25.createElement)("button", {
+ }, (0, import_react24.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react24.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
+ (0, import_react24.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react24.createElement)("button", {
key: color,
type: "button",
className: "bt-color",
@@ -11192,19 +10995,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
title: t(capitalize(color)),
"aria-pressed": look.color === color,
onClick: () => onColor(color)
- }, (0, import_react25.createElement)("span", { style: { background: paintCss(color) } }))))
+ }, (0, import_react24.createElement)("span", { style: { background: paintCss(color) } }))))
);
}
function AvatarEditor({ bot, actions, run, onClose }) {
const look = lookOf(bot);
- const [tab, setTab] = (0, import_react25.useState)(look.image ? "upload" : "bot");
- const [over, setOver] = (0, import_react25.useState)(false);
- const ref = (0, import_react25.useRef)(null);
- const fileRef = (0, import_react25.useRef)(null);
+ const [tab, setTab] = (0, import_react24.useState)(look.image ? "upload" : "bot");
+ const [over, setOver] = (0, import_react24.useState)(false);
+ const ref = (0, import_react24.useRef)(null);
+ const fileRef = (0, import_react24.useRef)(null);
const upload = (file) => {
if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
};
- (0, import_react25.useEffect)(() => {
+ (0, import_react24.useEffect)(() => {
const onDown = (event) => {
if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
onClose();
@@ -11222,7 +11025,7 @@ function AvatarEditor({ bot, actions, run, onClose }) {
window.removeEventListener("keydown", onKey, true);
};
}, [onClose]);
- (0, import_react25.useEffect)(() => {
+ (0, import_react24.useEffect)(() => {
if (tab !== "upload") return void 0;
const onPaste = (event) => {
const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/"));
@@ -11236,32 +11039,32 @@ function AvatarEditor({ bot, actions, run, onClose }) {
}, [tab, bot]);
const hasPhoto = Boolean(look.image);
const glide = useGlide(tab);
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
"div",
{ ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-avedit-top" },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
- (0, import_react25.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react25.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
+ (0, import_react24.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
),
- (0, import_react25.createElement)("button", {
+ (0, import_react24.createElement)("button", {
type: "button",
className: "bt-avedit-tab bt-avedit-reset",
title: t("Reset character to default"),
onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
}, t("Reset"))
),
- tab === "bot" ? (0, import_react25.createElement)(CharacterGrid, {
+ tab === "bot" ? (0, import_react24.createElement)(CharacterGrid, {
look,
hasPhoto,
onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })),
onColor: (color) => run(actions.updateBot(bot.id, { color }))
}) : null,
- tab === "upload" ? (0, import_react25.createElement)(
+ tab === "upload" ? (0, import_react24.createElement)(
"div",
{
className: "bt-dropzone",
@@ -11277,15 +11080,15 @@ function AvatarEditor({ bot, actions, run, onClose }) {
upload(event.dataTransfer.files?.[0]);
}
},
- hasPhoto ? (0, import_react25.createElement)("img", { src: look.image, alt: "" }) : null,
- (0, import_react25.createElement)("div", null, t("Drag, drop, or paste an image")),
- (0, import_react25.createElement)(
+ hasPhoto ? (0, import_react24.createElement)("img", { src: look.image, alt: "" }) : null,
+ (0, import_react24.createElement)("div", null, t("Drag, drop, or paste an image")),
+ (0, import_react24.createElement)(
"div",
{ className: "bt-actions", style: { justifyContent: "center" } },
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
- hasPhoto ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
+ hasPhoto ? (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
),
- (0, import_react25.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+ (0, import_react24.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
const file = event.target.files?.[0];
event.target.value = "";
upload(file);
@@ -11296,22 +11099,22 @@ function AvatarEditor({ bot, actions, run, onClose }) {
function MemoryCard({ bot, actions }) {
const count = useMemoryCount(bot.id, actions);
const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
Card,
{ title: t("Memory") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"button",
{ type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, label)),
- (0, import_react25.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react25.createElement)(ChevronRightIcon))
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, label)),
+ (0, import_react24.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react24.createElement)(ChevronRightIcon))
)
);
}
function BotSettingsForm({ bot, roster, actions, run }) {
- const [editing, setEditing] = (0, import_react25.useState)(false);
- const [confirming, setConfirming] = (0, import_react25.useState)(false);
- const closeEditor = (0, import_react25.useCallback)(() => setEditing(false), []);
- (0, import_react25.useEffect)(() => {
+ const [editing, setEditing] = (0, import_react24.useState)(false);
+ const [confirming, setConfirming] = (0, import_react24.useState)(false);
+ const closeEditor = (0, import_react24.useCallback)(() => setEditing(false), []);
+ (0, import_react24.useEffect)(() => {
if (!confirming) return void 0;
const timer = setTimeout(() => setConfirming(false), 4e3);
return () => clearTimeout(timer);
@@ -11326,116 +11129,116 @@ function BotSettingsForm({ bot, roster, actions, run }) {
if (confirming) void run(actions.deleteBot(bot.id));
else setConfirming(true);
};
- (0, import_react25.useEffect)(() => {
+ (0, import_react24.useEffect)(() => {
void actions.refreshModels?.();
}, [bot.id]);
- return (0, import_react25.createElement)(
+ return (0, import_react24.createElement)(
"div",
{ className: "bt-bs" },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-bs-hero" },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-avatar-anchor" },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"button",
{ type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) },
- (0, import_react25.createElement)(BotMark, { bot, size: 64, live: true }),
- (0, import_react25.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react25.createElement)(PencilIcon))
+ (0, import_react24.createElement)(BotMark, { bot, size: 64, live: true }),
+ (0, import_react24.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react24.createElement)(PencilIcon))
),
- editing ? (0, import_react25.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
+ editing ? (0, import_react24.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-bs-who" },
- (0, import_react25.createElement)("span", { className: "bt-bs-name" }, bot.name),
- (0, import_react25.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
+ (0, import_react24.createElement)("span", { className: "bt-bs-name" }, bot.name),
+ (0, import_react24.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
)
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Card,
{ title: t("Profile") },
- (0, import_react25.createElement)(Row, { label: t("Name") }, (0, import_react25.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
- (0, import_react25.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react25.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
+ (0, import_react24.createElement)(Row, { label: t("Name") }, (0, import_react24.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
+ (0, import_react24.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react24.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Card,
{ title: t("Model") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
- (0, import_react25.createElement)(ModelMenu, { bot, roster, actions, run })
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
+ (0, import_react24.createElement)(ModelMenu, { bot, roster, actions, run })
),
- bot.model ? (0, import_react25.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
+ bot.model ? (0, import_react24.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Card,
{ title: t("Working folder") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
- (0, import_react25.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+ (0, import_react24.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
)
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Card,
{ title: t("Instructions") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Row,
{ label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true },
- (0, import_react25.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
+ (0, import_react24.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
)
),
// Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406).
- (0, import_react25.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react25.createElement)(Card, { title: t("Sharing") }, children) }),
- (0, import_react25.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
- (0, import_react25.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
- roster.memory === false ? null : (0, import_react25.createElement)(MemoryCard, { bot, actions }),
- (0, import_react25.createElement)(BotSchedules, { bot, roster, actions }),
- (0, import_react25.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react24.createElement)(Card, { title: t("Sharing") }, children) }),
+ (0, import_react24.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
+ (0, import_react24.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
+ roster.memory === false ? null : (0, import_react24.createElement)(MemoryCard, { bot, actions }),
+ (0, import_react24.createElement)(BotSchedules, { bot, roster, actions }),
+ (0, import_react24.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
+ (0, import_react24.createElement)(
Card,
{ title: t("Main Bot") },
- onlyMain ? (0, import_react25.createElement)(
+ onlyMain ? (0, import_react24.createElement)(
"div",
{ className: "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
),
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
- ) : (0, import_react25.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
+ ) : (0, import_react24.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
),
// The Main Bot tile is always first and always shown, like in the context menu.
- isTile ? null : (0, import_react25.createElement)(
+ isTile ? null : (0, import_react24.createElement)(
Card,
{ title: t("Sidebar") },
- (0, import_react25.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
- isMainBot ? null : (0, import_react25.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
+ (0, import_react24.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
+ isMainBot ? null : (0, import_react24.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
),
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
Card,
{ title: t("More") },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"button",
{ type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
- (0, import_react25.createElement)("span", { className: "bt-chevron" }, (0, import_react25.createElement)(ChevronRightIcon))
+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
+ (0, import_react24.createElement)("span", { className: "bt-chevron" }, (0, import_react24.createElement)(ChevronRightIcon))
),
- isMainBot ? null : (0, import_react25.createElement)(
+ isMainBot ? null : (0, import_react24.createElement)(
"button",
{ type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
- (0, import_react25.createElement)(
+ (0, import_react24.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react25.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
- (0, import_react25.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
+ (0, import_react24.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
),
- (0, import_react25.createElement)("span", { className: "bt-chevron" }, (0, import_react25.createElement)(ChevronRightIcon))
+ (0, import_react24.createElement)("span", { className: "bt-chevron" }, (0, import_react24.createElement)(ChevronRightIcon))
)
)
);
@@ -11475,137 +11278,741 @@ ${MODEL_TEST_CSS}
@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
`;
-// src/client/bot-draft.js
-function freshBotName(roster, base = "New Bot") {
- const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
- if (!taken.has(base.toLowerCase())) return base;
- let index = 2;
- while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
- return `${base} ${index}`;
-}
-function startModel(roster) {
- const models = roster.models ?? [];
- const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
- const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
- return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
-}
-function createPayload({ name, look, image, model, effort }) {
- const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
- if (model) payload.model = model;
- if (effort) payload.effort = effort;
- return payload;
+// src/client/group-settings.js
+var import_react25 = require("react");
+var ROOM_MODES = [
+ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
+ ["everyone", "Everyone", "Every member answers in turn, admin first."]
+];
+var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
+function faceRoomCount(room2, roster) {
+ const people2 = facePeople(room2, roster).length;
+ const seats = faceSeats(room2, roster).length;
+ const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
+ const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
+ return `${persons} · ${bots}`;
}
-async function afterCreate({ id, text, actions }) {
- actions.openSession(id);
- actions.showDetails(id, "settings");
- const line2 = String(text ?? "").trim();
- if (line2 === "") return;
- try {
- await actions.send(id, line2);
- } catch (failure) {
- actions.notify?.(failure?.message ?? String(failure));
- }
+var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
+var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
+function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
+ const bots = person.seats.flatMap((seat, index) => {
+ const name = seat.local && onOpenBot ? (0, import_react25.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react25.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
+ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react25.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
+ return index === 0 ? marked : [(0, import_react25.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
+ });
+ return (0, import_react25.createElement)(
+ "div",
+ { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-gs-who bt-face-person" },
+ (0, import_react25.createElement)(PersonMark, { size, name: person.name }),
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-face-person-text" },
+ (0, import_react25.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
+ (0, import_react25.createElement)("span", { className: "bt-face-bots" }, bots)
+ )
+ )
+ );
}
-
-// src/client/create-bot.js
-var import_react26 = require("react");
-var pick = (list) => list[Math.floor(Math.random() * list.length)];
-var randomLook = (avoid = {}) => {
- const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
- const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
- return { shape: pick(shapes), color: pick(colors) };
-};
-function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
- const [look, setLook] = (0, import_react26.useState)(() => randomLook());
- const [image, setImage] = (0, import_react26.useState)(null);
- const [name, setName] = (0, import_react26.useState)(start?.name ?? "");
- const [model, setModel] = (0, import_react26.useState)(() => startModel(roster));
- const [effort, setEffort] = (0, import_react26.useState)(void 0);
- const [busy, setBusy] = (0, import_react26.useState)(false);
- const [error, setError] = (0, import_react26.useState)("");
- const fileRef = (0, import_react26.useRef)(null);
- const frame = (0, import_react26.useRef)(null);
- useEscape(onBack);
- (0, import_react26.useEffect)(() => {
- if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
- }, []);
- const fallbackName = freshBotName(roster, t("New Bot"));
- const finalName = name.trim() || fallbackName;
- const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
- const problem = nameProblem(finalName);
- const create = async () => {
- if (busy || clash || problem) return;
- setError("");
+function FaceLead({ room: room2, roster, pick: pick2 }) {
+ const lead = room2.face?.lead;
+ const [open, setOpen] = (0, import_react25.useState)(false);
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ const [error, setError] = (0, import_react25.useState)("");
+ const seats = faceSeats(room2, roster);
+ const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
+ if (!current) return null;
+ const canPick = lead.canPick === true && typeof pick2 === "function";
+ const choose = async (handle) => {
setBusy(true);
+ setError("");
try {
- const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
- await onCreated(bot);
- } catch (failure) {
- setError(failure?.message ?? String(failure));
+ await pick2(handle);
+ setOpen(false);
+ } catch (err) {
+ setError(err?.message || t("Could not change the room lead."));
+ } finally {
setBusy(false);
}
};
- const upload = (file) => {
- if (!file) return;
- setError("");
- avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
- };
- const previewLook = image ? { ...look, image } : look;
- const card = (0, import_react26.createElement)(
+ return (0, import_react25.createElement)(
"div",
- { className: "bt-create-card" },
- (0, import_react26.createElement)(
+ { className: "bt-face-lead", "data-flybot": "face-lead" },
+ (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
+ (0, import_react25.createElement)(
"div",
- { className: "bt-create-preview" },
- // The key remounts the mark, so every new pick plays the pop.
- (0, import_react26.createElement)(
+ { className: "bt-set-row" },
+ (0, import_react25.createElement)(
"span",
- { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
- (0, import_react26.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
+ { className: "bt-set-copy" },
+ (0, import_react25.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
),
- (0, import_react26.createElement)("span", { className: "bt-create-name" }, finalName)
+ canPick ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
),
- (0, import_react26.createElement)("div", { className: "bt-create-step" }, t("Look")),
- (0, import_react26.createElement)(
+ open && canPick ? seats.map((seat) => (0, import_react25.createElement)(
"div",
- { className: "bt-create-looks" },
- (0, import_react26.createElement)(CharacterGrid, {
- look,
- hasPhoto: Boolean(image),
- onShape: (shape) => {
- setImage(null);
- setLook((current) => ({ ...current, shape }));
- },
- onColor: (color) => {
- setImage(null);
- setLook((current) => ({ ...current, color }));
- }
- }),
- (0, import_react26.createElement)(
- "div",
- { className: "bt-actions bt-create-lookbar" },
- (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
- setImage(null);
- setLook((current) => randomLook(current));
- } }, t("Shuffle")),
- (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
- image ? (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
- (0, import_react26.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
- const file = event.target.files?.[0];
- event.target.value = "";
- upload(file);
- } })
- )
- ),
- (0, import_react26.createElement)(
- "label",
- { className: "bt-field" },
- (0, import_react26.createElement)("span", { className: "bt-field-label" }, t("Name")),
- (0, import_react26.createElement)("input", {
- className: "bt-input",
- autoFocus: !inline,
- value: name,
- placeholder: fallbackName,
+ { key: seat.key, className: "bt-set-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
+ seat === current ? (0, import_react25.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react25.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
+ )) : null,
+ open && canPick && lead.picked ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
+ canPick || !lead.supported ? null : (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
+ error ? (0, import_react25.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
+ );
+}
+function seatedCount(room2) {
+ const live = room2?.presentedParticipants ?? room2?.participants ?? [];
+ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length;
+ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length;
+ return humans + remotes + (room2?.members?.length ?? 0);
+}
+function roomSubtitle(room2, roster) {
+ if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
+ const admin = roster.byId[room2.admin];
+ const count = seatedCount(room2);
+ return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
+}
+function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
+ const [adding, setAdding] = (0, import_react25.useState)(false);
+ const [confirming, setConfirming] = (0, import_react25.useState)(false);
+ (0, import_react25.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(false), 4e3);
+ return () => clearTimeout(timer);
+ }, [confirming]);
+ const live = room2.presentedParticipants ?? room2.participants ?? [];
+ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
+ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
+ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+ const face = room2.origin === "face-to-face";
+ const people2 = face ? facePeople(room2, roster) : [];
+ const shared2 = humans.length > 1;
+ const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
+ const outside = roster.bots.filter((bot) => {
+ if (bot.remote) return false;
+ if (room2.members.includes(bot.id)) return false;
+ if (shared2) return chiefs.has(bot.id);
+ return true;
+ });
+ const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
+ const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
+ const admin = roster.byId[room2.admin];
+ const mode = shownMode(room2, roster);
+ const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
+ const update = (patch) => run(actions.updateRoom(room2.id, patch));
+ const openBot = (id) => {
+ actions.closeSettings();
+ actions.openSession(id);
+ };
+ const remove = () => {
+ if (!confirming) {
+ setConfirming(true);
+ return;
+ }
+ void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
+ if (done) onDeleted?.();
+ });
+ };
+ return (0, import_react25.createElement)(
+ "div",
+ { className: "bt-bs" },
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-bs-hero" },
+ (0, import_react25.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-bs-who" },
+ (0, import_react25.createElement)("span", { className: "bt-bs-name" }, room2.name),
+ (0, import_react25.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
+ )
+ ),
+ (0, import_react25.createElement)(
+ Card,
+ { title: t("Profile") },
+ (0, import_react25.createElement)(
+ Row,
+ { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
+ (0, import_react25.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
+ ),
+ (0, import_react25.createElement)(
+ Row,
+ { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
+ (0, import_react25.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
+ )
+ ),
+ (0, import_react25.createElement)(
+ Card,
+ { title: t("Who answers") },
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react25.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react25.createElement)("button", {
+ key: id,
+ type: "button",
+ "aria-pressed": mode === id,
+ disabled: id === "admin" && !admin,
+ title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
+ onClick: () => {
+ if (mode !== id) void update({ mode: id });
+ }
+ }, t(label)))),
+ (0, import_react25.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
+ )
+ ),
+ (0, import_react25.createElement)(
+ Card,
+ { title: t("Working folder") },
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+ (0, import_react25.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
+ )
+ ),
+ face ? (0, import_react25.createElement)(
+ Card,
+ { title: t("Members · {count}", { count: people2.length }) },
+ people2.map((person) => (0, import_react25.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
+ ) : null,
+ face ? null : (0, import_react25.createElement)(
+ Card,
+ { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
+ humans.map((person) => (0, import_react25.createElement)(
+ "div",
+ { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-gs-who" },
+ (0, import_react25.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
+ (0, import_react25.createElement)("span", { className: "bt-tag" }, t("Human")),
+ person.role === "admin" ? (0, import_react25.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
+ ),
+ humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react25.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
+ )),
+ members.map((member) => {
+ const leads = room2.admin === member.id;
+ return (0, import_react25.createElement)(
+ "div",
+ { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react25.createElement)(
+ "button",
+ { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
+ (0, import_react25.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
+ (0, import_react25.createElement)("span", { className: "bt-row-title" }, member.name),
+ leads ? (0, import_react25.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
+ (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react25.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
+ ),
+ leads ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react25.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
+ members.length > 2 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
+ );
+ }),
+ remotes.map((person) => (0, import_react25.createElement)(
+ "div",
+ { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-gs-who" },
+ (0, import_react25.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
+ (0, import_react25.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
+ // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
+ // labelled as such; a Bot with no Bot Social identity can never be written to at all.
+ room2.origin === "face-to-face" ? null : (0, import_react25.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
+ ),
+ // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
+ room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react25.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
+ )),
+ room2.origin === "face-to-face" ? null : adding ? [
+ ...outsidePeople.map((person) => (0, import_react25.createElement)(
+ "button",
+ { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
+ } },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
+ )),
+ ...outside.map((bot) => (0, import_react25.createElement)(
+ "button",
+ { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ add: [bot.id] });
+ } },
+ (0, import_react25.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, bot.name)),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Add"))
+ )),
+ ...outsideRemotes.map((bot) => (0, import_react25.createElement)(
+ "button",
+ { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
+ } },
+ (0, import_react25.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
+ )),
+ (0, import_react25.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_react25.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react25.createElement)(PlusIcon), t("Add member")) : null,
+ (0, import_react25.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react25.createElement)(
+ "span",
+ { className: "bt-set-hint" },
+ // The host checks for two members on every change, even a rename.
+ members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")
+ )),
+ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
+ // the note on who leads, so that note stays with the members it is about (#892).
+ room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react25.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
+ ),
+ (0, import_react25.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
+ (0, import_react25.createElement)(
+ Card,
+ { title: t("Sidebar") },
+ (0, import_react25.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
+ (0, import_react25.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
+ ),
+ (0, import_react25.createElement)(
+ Card,
+ { title: t("More") },
+ (0, import_react25.createElement)(
+ "button",
+ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react25.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
+ ),
+ (0, import_react25.createElement)("span", { className: "bt-chevron" }, (0, import_react25.createElement)(ChevronRightIcon))
+ )
+ )
+ );
+}
+function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
+ const [admin, setAdmin] = (0, import_react25.useState)(null);
+ const [members, setMembers] = (0, import_react25.useState)([]);
+ const [name, setName] = (0, import_react25.useState)("");
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
+ const pickAdmin = (id) => {
+ setAdmin(id);
+ setMembers((list) => list.filter((item) => item !== id));
+ };
+ const create = async () => {
+ if (busy || admin === null || members.length === 0) return;
+ setBusy(true);
+ let room2;
+ const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
+ room2 = value;
+ }));
+ if (done && room2) onCreated(room2);
+ else setBusy(false);
+ };
+ const chip = (bot, on, onClick, role) => (0, import_react25.createElement)("button", {
+ key: bot.id,
+ type: "button",
+ role,
+ className: "bt-ng-chip",
+ "aria-checked": on,
+ onClick
+ }, (0, import_react25.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react25.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react25.createElement)(AdminStar) : null);
+ return (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-card bt-ng" },
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react25.createElement)(AdminStar), t("Choose the group admin"))),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
+ roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
+ )
+ ),
+ admin === null ? null : (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
+ roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
+ )
+ ),
+ admin === null || members.length === 0 ? null : (0, import_react25.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-ng-step" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+ (0, import_react25.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
+ ),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-task-foot" },
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
+ )
+ );
+}
+var GROUP_SETTINGS_CSS = `
+.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
+.bt-ng-step{animation:bt-veil-in .22s ease-out}
+.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
+.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
+.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
+.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
+.bt-ng-chip:active{scale:.96}
+.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
+.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
+.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
+.bt-gs-member{gap:8px}
+.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
+.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
+.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
+.bt-face-person{cursor:default}
+.bt-face-person:hover .bt-row-title{text-decoration:none}
+.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
+.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
+button.bt-face-bot{cursor:pointer}
+button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
+.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
+.bt-bs-stack .bt-seg{align-self:stretch}
+@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
+`;
+
+// src/client/palette.js
+var import_react26 = require("react");
+var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
+var PALETTE_EXIT_MS = 160;
+function CommandPalette({ start, token, roster, actions, useSessions }) {
+ const [query, setQuery] = (0, import_react26.useState)(start ?? "");
+ const [cursor, setCursor] = (0, import_react26.useState)(0);
+ const [leaving, setLeaving] = (0, import_react26.useState)(false);
+ const wanted = query.trim().toLowerCase();
+ const close = () => {
+ if (leaving) return;
+ setLeaving(true);
+ setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
+ };
+ useEscape(close);
+ const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
+ const found = useSessions((list) => {
+ if (wanted.length < 2) return "[]";
+ const hits = [];
+ for (const id of ids) {
+ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
+ if (!Array.isArray(outline)) continue;
+ const hidden = roster.exchangeTurns[id] ?? [];
+ for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
+ const entry = outline[index2];
+ if (hidden.includes(entry.turn)) continue;
+ for (const raw of [entry.response, entry.prompt]) {
+ const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim();
+ const at = text.toLowerCase().indexOf(wanted);
+ if (at < 0) continue;
+ const from = Math.max(0, at - 24);
+ hits.push([id, entry.turn, (from > 0 ? "…" : "") + text.slice(from, at + wanted.length + 60)]);
+ break;
+ }
+ }
+ }
+ return JSON.stringify(hits);
+ });
+ const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
+ const sections = [];
+ const people2 = [
+ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
+ key: bot.id,
+ avatar: (0, import_react26.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
+ title: pickerBotLabel(bot),
+ subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
+ badge: bot.hidden ? t("Hidden") : void 0,
+ run: async () => {
+ if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
+ actions.openSession(bot.id);
+ }
+ })),
+ ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
+ key: room2.id,
+ avatar: (0, import_react26.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+ title: room2.name,
+ subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }),
+ badge: room2.hidden ? t("Hidden") : void 0,
+ run: async () => {
+ if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
+ actions.openSession(room2.id);
+ }
+ }))
+ ];
+ if (people2.length) sections.push([t("Bots"), people2]);
+ const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
+ key: agent.id,
+ avatar: (0, import_react26.createElement)("span", { className: "bt-palette-agent" }, (0, import_react26.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
+ title: "DSH Agent",
+ subtitle: t("Plain dsh session"),
+ run: () => actions.openAgent(agent.id)
+ }));
+ if (agents.length) sections.push(["DSH Agent", agents]);
+ const messages = JSON.parse(found).map(([id, turn, snippet]) => {
+ const bot = roster.byId[id];
+ const room2 = roster.roomsById[id];
+ return {
+ key: `${id}:${turn}`,
+ title: bot?.name ?? room2?.name ?? t("Bot"),
+ subtitle: snippet,
+ avatar: bot ? (0, import_react26.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react26.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+ run: () => actions.openSession(id)
+ };
+ });
+ if (messages.length) sections.push([t("Messages"), messages]);
+ const commands = [
+ { key: "cmd:new", icon: (0, import_react26.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
+ { key: "cmd:bot", icon: (0, import_react26.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
+ { key: "cmd:group", icon: (0, import_react26.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
+ { key: "cmd:settings", icon: (0, import_react26.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
+ ].filter((command) => matchesName(command.title));
+ if (commands.length) sections.push([t("Commands"), commands]);
+ const rows = sections.flatMap(([, items]) => items);
+ const active = Math.min(cursor, Math.max(rows.length - 1, 0));
+ const shown = rows.map((row) => row.key).join("\n");
+ const glide = useGlide(`${active}
+${shown}`, { axis: "y", ...QUICK_EDGES });
+ const listRef = glide.box;
+ useFlip(listRef, shown, "[data-flip]");
+ (0, import_react26.useEffect)(() => {
+ listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
+ }, [active, query]);
+ const choose = (row) => {
+ if (!row || leaving) return;
+ void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
+ close();
+ };
+ let index = -1;
+ return (0, import_react26.createElement)(
+ "div",
+ { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
+ (0, import_react26.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
+ (0, import_react26.createElement)(
+ "div",
+ { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
+ (0, import_react26.createElement)(
+ "div",
+ { className: "bt-palette-head" },
+ (0, import_react26.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react26.createElement)(SearchIcon)),
+ (0, import_react26.createElement)("input", {
+ autoFocus: true,
+ value: query,
+ placeholder: t("Search Bots, chats and messages"),
+ "aria-label": t("Search"),
+ role: "combobox",
+ "aria-expanded": true,
+ "aria-controls": "bt-palette-list",
+ onChange: (event) => {
+ setQuery(event.target.value);
+ setCursor(0);
+ },
+ onKeyDown: (event) => {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setCursor(Math.min(active + 1, rows.length - 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setCursor(Math.max(active - 1, 0));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ choose(rows[active]);
+ }
+ }
+ }),
+ query ? (0, import_react26.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
+ setQuery("");
+ setCursor(0);
+ } }, (0, import_react26.createElement)(CloseIcon)) : null
+ ),
+ (0, import_react26.createElement)(
+ "div",
+ { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
+ (0, import_react26.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ rows.length === 0 ? (0, import_react26.createElement)(
+ "div",
+ { className: "bt-palette-empty" },
+ (0, import_react26.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react26.createElement)(SearchIcon)),
+ (0, import_react26.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
+ (0, import_react26.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
+ ) : sections.map(([title, items]) => (0, import_react26.createElement)(
+ import_react26.Fragment,
+ { key: title },
+ (0, import_react26.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
+ items.map((row) => {
+ index += 1;
+ const at = index;
+ return (0, import_react26.createElement)(
+ "button",
+ {
+ key: row.key,
+ type: "button",
+ role: "option",
+ className: "bt-palette-row",
+ "aria-selected": at === active,
+ "data-flip": row.key,
+ onMouseMove: () => {
+ if (cursor !== at) setCursor(at);
+ },
+ onClick: () => choose(row)
+ },
+ (0, import_react26.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react26.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
+ (0, import_react26.createElement)(
+ "span",
+ { className: "bt-palette-text" },
+ (0, import_react26.createElement)("span", { className: "bt-palette-title" }, row.title),
+ row.subtitle ? (0, import_react26.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
+ ),
+ row.badge ? (0, import_react26.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
+ );
+ })
+ ))
+ )
+ )
+ );
+}
+
+// src/client/bot-draft.js
+function freshBotName(roster, base = "New Bot") {
+ const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
+ if (!taken.has(base.toLowerCase())) return base;
+ let index = 2;
+ while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
+ return `${base} ${index}`;
+}
+function startModel(roster) {
+ const models = roster.models ?? [];
+ const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
+ const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
+ return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
+}
+function createPayload({ name, look, image, model, effort }) {
+ const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
+ if (model) payload.model = model;
+ if (effort) payload.effort = effort;
+ return payload;
+}
+async function afterCreate({ id, text, actions }) {
+ actions.openSession(id);
+ actions.showDetails(id, "settings");
+ const line2 = String(text ?? "").trim();
+ if (line2 === "") return;
+ try {
+ await actions.send(id, line2);
+ } catch (failure) {
+ actions.notify?.(failure?.message ?? String(failure));
+ }
+}
+
+// src/client/create-bot.js
+var import_react27 = require("react");
+var pick = (list) => list[Math.floor(Math.random() * list.length)];
+var randomLook = (avoid = {}) => {
+ const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
+ const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
+ return { shape: pick(shapes), color: pick(colors) };
+};
+function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
+ const [look, setLook] = (0, import_react27.useState)(() => randomLook());
+ const [image, setImage] = (0, import_react27.useState)(null);
+ const [name, setName] = (0, import_react27.useState)(start?.name ?? "");
+ const [model, setModel] = (0, import_react27.useState)(() => startModel(roster));
+ const [effort, setEffort] = (0, import_react27.useState)(void 0);
+ const [busy, setBusy] = (0, import_react27.useState)(false);
+ const [error, setError] = (0, import_react27.useState)("");
+ const fileRef = (0, import_react27.useRef)(null);
+ const frame = (0, import_react27.useRef)(null);
+ useEscape(onBack);
+ (0, import_react27.useEffect)(() => {
+ if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
+ }, []);
+ const fallbackName = freshBotName(roster, t("New Bot"));
+ const finalName = name.trim() || fallbackName;
+ const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
+ const problem = nameProblem(finalName);
+ const create = async () => {
+ if (busy || clash || problem) return;
+ setError("");
+ setBusy(true);
+ try {
+ const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
+ await onCreated(bot);
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
+ setBusy(false);
+ }
+ };
+ const upload = (file) => {
+ if (!file) return;
+ setError("");
+ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
+ };
+ const previewLook = image ? { ...look, image } : look;
+ const card = (0, import_react27.createElement)(
+ "div",
+ { className: "bt-create-card" },
+ (0, import_react27.createElement)(
+ "div",
+ { className: "bt-create-preview" },
+ // The key remounts the mark, so every new pick plays the pop.
+ (0, import_react27.createElement)(
+ "span",
+ { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
+ (0, import_react27.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
+ ),
+ (0, import_react27.createElement)("span", { className: "bt-create-name" }, finalName)
+ ),
+ (0, import_react27.createElement)("div", { className: "bt-create-step" }, t("Look")),
+ (0, import_react27.createElement)(
+ "div",
+ { className: "bt-create-looks" },
+ (0, import_react27.createElement)(CharacterGrid, {
+ look,
+ hasPhoto: Boolean(image),
+ onShape: (shape) => {
+ setImage(null);
+ setLook((current) => ({ ...current, shape }));
+ },
+ onColor: (color) => {
+ setImage(null);
+ setLook((current) => ({ ...current, color }));
+ }
+ }),
+ (0, import_react27.createElement)(
+ "div",
+ { className: "bt-actions bt-create-lookbar" },
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ setImage(null);
+ setLook((current) => randomLook(current));
+ } }, t("Shuffle")),
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
+ image ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
+ (0, import_react27.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ upload(file);
+ } })
+ )
+ ),
+ (0, import_react27.createElement)(
+ "label",
+ { className: "bt-field" },
+ (0, import_react27.createElement)("span", { className: "bt-field-label" }, t("Name")),
+ (0, import_react27.createElement)("input", {
+ className: "bt-input",
+ autoFocus: !inline,
+ value: name,
+ placeholder: fallbackName,
maxLength: 40,
"aria-label": t("Name"),
onChange: (event) => setName(event.target.value),
@@ -11616,59 +12023,59 @@ function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inlin
}
}
}),
- problem ? (0, import_react26.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react26.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
+ problem ? (0, import_react27.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react27.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
),
- (0, import_react26.createElement)(
+ (0, import_react27.createElement)(
"div",
{ className: "bt-field" },
- (0, import_react26.createElement)("span", { className: "bt-field-label" }, t("Model")),
- (0, import_react26.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
+ (0, import_react27.createElement)("span", { className: "bt-field-label" }, t("Model")),
+ (0, import_react27.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
setModel(ref);
setEffort(void 0);
}, onPickEffort: setEffort }),
- (0, import_react26.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
+ (0, import_react27.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
),
- error ? (0, import_react26.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
- (0, import_react26.createElement)(
+ error ? (0, import_react27.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
+ (0, import_react27.createElement)(
"div",
{ className: "bt-create-foot" },
// The dialog's header already has Back and Close (#230); only the inline form needs its own.
- inline ? (0, import_react26.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
- (0, import_react26.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
+ inline ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
)
);
- if (inline) return (0, import_react26.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
- return (0, import_react26.createElement)(
+ if (inline) return (0, import_react27.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
+ return (0, import_react27.createElement)(
"div",
{ className: "bt-create", role: "dialog", "aria-label": t("Create Bot") },
- (0, import_react26.createElement)(
+ (0, import_react27.createElement)(
"div",
{ className: "bt-create-head" },
- canGoBack ? (0, import_react26.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react26.createElement)(ChevronLeftIcon)) : (0, import_react26.createElement)("span", { className: "bt-create-gap" }),
- (0, import_react26.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
- (0, import_react26.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react26.createElement)(CloseIcon))
+ canGoBack ? (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react27.createElement)(ChevronLeftIcon)) : (0, import_react27.createElement)("span", { className: "bt-create-gap" }),
+ (0, import_react27.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
+ (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react27.createElement)(CloseIcon))
),
- (0, import_react26.createElement)("div", { className: "bt-create-body" }, card)
+ (0, import_react27.createElement)("div", { className: "bt-create-body" }, card)
);
}
// src/client/new-chat.js
-var import_react27 = require("react");
+var import_react28 = require("react");
function NewChat({ mode, roster, actions }) {
- const [query, setQuery] = (0, import_react27.useState)("");
- const [picked, setPicked] = (0, import_react27.useState)([]);
- const [group, setGroup] = (0, import_react27.useState)(mode === "group");
- const [compose, setCompose] = (0, import_react27.useState)(mode !== "new");
- const [admin, setAdmin] = (0, import_react27.useState)(null);
- const [message, setMessage] = (0, import_react27.useState)("");
- const [busy, setBusy] = (0, import_react27.useState)(false);
- const [error, setError] = (0, import_react27.useState)("");
- const [fix, setFix] = (0, import_react27.useState)(null);
- const [fixNote, setFixNote] = (0, import_react27.useState)("");
- const [inviteLink, setInviteLink] = (0, import_react27.useState)("");
- const [cursor, setCursor] = (0, import_react27.useState)(0);
- const [draft, setDraft] = (0, import_react27.useState)(mode === "create" ? { name: "", fromList: false } : null);
- const inputRef = (0, import_react27.useRef)(null);
+ const [query, setQuery] = (0, import_react28.useState)("");
+ const [picked, setPicked] = (0, import_react28.useState)([]);
+ const [group, setGroup] = (0, import_react28.useState)(mode === "group");
+ const [compose, setCompose] = (0, import_react28.useState)(mode !== "new");
+ const [admin, setAdmin] = (0, import_react28.useState)(null);
+ const [message, setMessage] = (0, import_react28.useState)("");
+ const [busy, setBusy] = (0, import_react28.useState)(false);
+ const [error, setError] = (0, import_react28.useState)("");
+ const [fix, setFix] = (0, import_react28.useState)(null);
+ const [fixNote, setFixNote] = (0, import_react28.useState)("");
+ const [inviteLink, setInviteLink] = (0, import_react28.useState)("");
+ const [cursor, setCursor] = (0, import_react28.useState)(0);
+ const [draft, setDraft] = (0, import_react28.useState)(mode === "create" ? { name: "", fromList: false } : null);
+ const inputRef = (0, import_react28.useRef)(null);
const close = () => actions.closeOverlay();
useEscape(close);
const paneLeft = usePaneLeft();
@@ -11799,14 +12206,14 @@ function NewChat({ mode, roster, actions }) {
const options = [];
if (!group && picked.length === 0 && mode !== "search" && wanted === "") {
if (menuOnly) {
- options.push({ key: "chat", icon: (0, import_react27.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
+ options.push({ key: "chat", icon: (0, import_react28.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
setCompose(true);
setCursor(0);
setTimeout(() => inputRef.current?.focus());
} });
}
- options.push({ key: "new", icon: (0, import_react27.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
- options.push({ key: "group", icon: (0, import_react27.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
+ options.push({ key: "new", icon: (0, import_react28.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
+ options.push({ key: "group", icon: (0, import_react28.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
setGroup(true);
setCompose(true);
setCursor(0);
@@ -11815,7 +12222,7 @@ function NewChat({ mode, roster, actions }) {
for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
options.push({
key: action.key,
- icon: action.icon ? (0, import_react27.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react27.createElement)(PeopleIcon) }) : (0, import_react27.createElement)(PeopleIcon),
+ icon: action.icon ? (0, import_react28.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react28.createElement)(PeopleIcon) }) : (0, import_react28.createElement)(PeopleIcon),
label: t(action.label),
hint: action.hint ? t(action.hint) : void 0,
run: () => {
@@ -11827,7 +12234,7 @@ function NewChat({ mode, roster, actions }) {
});
}
if ((roster.agents ?? []).length === 0) {
- options.push({ key: "agent", icon: (0, import_react27.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
+ options.push({ key: "agent", icon: (0, import_react28.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
await actions.addAgent();
close();
}) });
@@ -11838,19 +12245,19 @@ function NewChat({ mode, roster, actions }) {
options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) });
}
}
- if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react27.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
+ if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react28.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
const active = Math.min(cursor, Math.max(options.length - 1, 0));
const listed = options.map((option) => option.key).join("\n");
const glide = useGlide(`${active}
${listed}`, { axis: "y", ...QUICK_EDGES });
useFlip(glide.box, listed, "[data-flip]");
- const drop = (0, import_react27.useRef)(null);
+ const drop = (0, import_react28.useRef)(null);
useMorph(drop, listed);
if (draft) {
- return (0, import_react27.createElement)(
+ return (0, import_react28.createElement)(
"div",
{ className: "bt-pane", style: { left: paneLeft } },
- (0, import_react27.createElement)(CreateBot, {
+ (0, import_react28.createElement)(CreateBot, {
roster,
actions,
start: draft,
@@ -11870,7 +12277,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
if (picked.length > 0) setPicked((current) => current.slice(0, -1));
else if (admin !== null) setAdmin(null);
};
- return (0, import_react27.createElement)(
+ return (0, import_react28.createElement)(
"div",
{
className: "bt-pane",
@@ -11880,31 +12287,31 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
if (event.target === event.currentTarget) close();
} : void 0
},
- (0, import_react27.createElement)(
+ (0, import_react28.createElement)(
"div",
{ className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 },
// The popover needs no title row: Escape or a press outside closes it.
- menuOnly ? null : (0, import_react27.createElement)(
+ menuOnly ? null : (0, import_react28.createElement)(
"div",
{ className: "bt-to" },
- (0, import_react27.createElement)("span", { className: "bt-to-label" }, t("To:")),
- adminBot ? (0, import_react27.createElement)(
+ (0, import_react28.createElement)("span", { className: "bt-to-label" }, t("To:")),
+ adminBot ? (0, import_react28.createElement)(
"span",
{ key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") },
- (0, import_react27.createElement)(BotMark, { bot: adminBot, size: 16 }),
+ (0, import_react28.createElement)(BotMark, { bot: adminBot, size: 16 }),
adminBot.name,
- (0, import_react27.createElement)(AdminStar),
- (0, import_react27.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
+ (0, import_react28.createElement)(AdminStar),
+ (0, import_react28.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
) : null,
- choosingAdmin ? (0, import_react27.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react27.createElement)(AdminStar)) : null,
- pickedBots.map((bot) => (0, import_react27.createElement)(
+ choosingAdmin ? (0, import_react28.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react28.createElement)(AdminStar)) : null,
+ pickedBots.map((bot) => (0, import_react28.createElement)(
"span",
{ key: bot.id, className: "bt-token" },
- (0, import_react27.createElement)(BotMark, { bot, size: 16 }),
+ (0, import_react28.createElement)(BotMark, { bot, size: 16 }),
bot.name,
- (0, import_react27.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
+ (0, import_react28.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
)),
- (0, import_react27.createElement)("input", {
+ (0, import_react28.createElement)("input", {
ref: inputRef,
autoFocus: true,
value: query,
@@ -11928,20 +12335,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
} else if (event.key === "Backspace" && query === "") unpick();
}
}),
- group && admin !== null ? (0, import_react27.createElement)("button", {
+ group && admin !== null ? (0, import_react28.createElement)("button", {
type: "button",
className: "bt-send bt-to-create",
disabled: busy || picked.length === 0,
title: picked.length === 0 ? t("Add at least one more Bot") : void 0,
onClick: createGroup
}, t("Create group chat")) : null,
- (0, import_react27.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
+ (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
),
- (0, import_react27.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react27.createElement)(
+ (0, import_react28.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react28.createElement)(
"div",
{ ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" },
- (0, import_react27.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- options.length === 0 ? (0, import_react27.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react27.createElement)(
+ (0, import_react28.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ options.length === 0 ? (0, import_react28.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react28.createElement)(
"button",
{
key: option.key,
@@ -11953,36 +12360,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
onMouseEnter: () => setCursor(index),
onClick: option.run
},
- option.bot ? (0, import_react27.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react27.createElement)("span", { className: "bt-option-icon" }, option.icon),
+ option.bot ? (0, import_react28.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react28.createElement)("span", { className: "bt-option-icon" }, option.icon),
// An action row's description sits under its title and is always there (#283): a long line
// beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows.
// A Bot row's short hint stays beside the name and shows on the active row only.
- option.bot || !option.hint ? (0, import_react27.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react27.createElement)("span", { className: "bt-option-copy" }, (0, import_react27.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react27.createElement)("span", { className: "bt-option-sub" }, option.hint)),
- option.bot && option.hint && index === active ? (0, import_react27.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
+ option.bot || !option.hint ? (0, import_react28.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react28.createElement)("span", { className: "bt-option-copy" }, (0, import_react28.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react28.createElement)("span", { className: "bt-option-sub" }, option.hint)),
+ option.bot && option.hint && index === active ? (0, import_react28.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
))
)),
- menuOnly ? null : (0, import_react27.createElement)("div", { className: "bt-nc-spacer" }),
- error && fix ? (0, import_react27.createElement)(
+ menuOnly ? null : (0, import_react28.createElement)("div", { className: "bt-nc-spacer" }),
+ error && fix ? (0, import_react28.createElement)(
"div",
{ className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" },
- (0, import_react27.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
- (0, import_react27.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
- typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react27.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
+ (0, import_react28.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
+ (0, import_react28.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
+ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react28.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
void copyInvite(bot);
} }, t("Copy invite link for {name}", { name: bot.name })) : null,
- (0, import_react27.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
+ (0, import_react28.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
])),
- fixNote ? (0, import_react27.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
- inviteLink ? (0, import_react27.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
- ) : error ? (0, import_react27.createElement)("div", { className: "bt-error" }, error) : null,
- menuOnly ? null : (0, import_react27.createElement)(
+ fixNote ? (0, import_react28.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
+ inviteLink ? (0, import_react28.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
+ ) : error ? (0, import_react28.createElement)("div", { className: "bt-error" }, error) : null,
+ menuOnly ? null : (0, import_react28.createElement)(
"div",
{ className: "bt-nc-compose" },
- (0, import_react27.createElement)(
+ (0, import_react28.createElement)(
"div",
{ className: "bt-nc-card" },
- (0, import_react27.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react27.createElement)(PlusIcon)),
- (0, import_react27.createElement)("textarea", {
+ (0, import_react28.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react28.createElement)(PlusIcon)),
+ (0, import_react28.createElement)("textarea", {
rows: 1,
value: message,
placeholder,
@@ -11995,7 +12402,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
}
}
}),
- (0, import_react27.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react27.createElement)(ArrowUpIcon))
+ (0, import_react28.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react28.createElement)(ArrowUpIcon))
)
)
)
@@ -12003,18 +12410,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
}
// src/client/exchange.js
-var import_react28 = require("react");
+var import_react29 = require("react");
var SHEET_EXIT_MS = 220;
function ExchangeDialog({ pair, roster, actions }) {
- const [entries2, setEntries] = (0, import_react28.useState)(null);
- const [leaving, setLeaving] = (0, import_react28.useState)(false);
+ const [entries2, setEntries] = (0, import_react29.useState)(null);
+ const [leaving, setLeaving] = (0, import_react29.useState)(false);
const close = () => {
if (leaving) return;
setLeaving(true);
setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS);
};
useEscape(close);
- (0, import_react28.useEffect)(() => {
+ (0, import_react29.useEffect)(() => {
let stop = false;
let timer;
let seen = null;
@@ -12038,48 +12445,48 @@ function ExchangeDialog({ pair, roster, actions }) {
const right = roster.byId[pair[1]];
let lastDay = 0;
const paneLeft = usePaneLeft();
- return (0, import_react28.createElement)(
+ return (0, import_react29.createElement)(
"div",
{ className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 },
- (0, import_react28.createElement)(
+ (0, import_react29.createElement)(
"div",
{ className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") },
- (0, import_react28.createElement)("div", { className: "bt-head" }, (0, import_react28.createElement)(
+ (0, import_react29.createElement)("div", { className: "bt-head" }, (0, import_react29.createElement)(
"span",
{ className: "bt-pill", style: { cursor: "default" } },
- left ? (0, import_react28.createElement)(BotMark, { bot: left, size: 22 }) : null,
+ left ? (0, import_react29.createElement)(BotMark, { bot: left, size: 22 }) : null,
left?.name ?? "?",
- (0, import_react28.createElement)("span", { className: "bt-swap" }, "⇄"),
- right ? (0, import_react28.createElement)(BotMark, { bot: right, size: 22 }) : null,
+ (0, import_react29.createElement)("span", { className: "bt-swap" }, "⇄"),
+ right ? (0, import_react29.createElement)(BotMark, { bot: right, size: 22 }) : null,
right?.name ?? "?"
)),
- (0, import_react28.createElement)(
+ (0, import_react29.createElement)(
"div",
{ className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") },
- entries2 === null ? (0, import_react28.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react28.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
+ entries2 === null ? (0, import_react29.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react29.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" };
const showDay = entry.time - lastDay > 30 * 60 * 1e3;
lastDay = entry.time;
- return (0, import_react28.createElement)(
- import_react28.Fragment,
+ return (0, import_react29.createElement)(
+ import_react29.Fragment,
{ key: entry.id },
- showDay ? (0, import_react28.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
- (0, import_react28.createElement)(
+ showDay ? (0, import_react29.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
+ (0, import_react29.createElement)(
"div",
{ className: "bt-group-msg" },
- (0, import_react28.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
- (0, import_react28.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react28.createElement)("span", { className: "bt-lead" }, (0, import_react28.createElement)(BotMark, { bot, size: 22 })) })
+ (0, import_react29.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
+ (0, import_react29.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react29.createElement)("span", { className: "bt-lead" }, (0, import_react29.createElement)(BotMark, { bot, size: 22 })) })
)
);
})
),
- (0, import_react28.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react28.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
+ (0, import_react29.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react29.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
)
);
}
// src/client/voice.js
-var import_react29 = require("react");
+var import_react30 = require("react");
var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim();
var VOICE_LABELS = {
listening: "Listening",
@@ -12091,8 +12498,8 @@ var VOICE_LABELS = {
};
var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" };
function useMicLevel(enabled) {
- const [level, setLevel] = (0, import_react29.useState)(0);
- (0, import_react29.useEffect)(() => {
+ const [level, setLevel] = (0, import_react30.useState)(0);
+ (0, import_react30.useEffect)(() => {
if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0;
let stream = null;
let audio = null;
@@ -12142,18 +12549,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0;
return entry ? `${outline.length}|${entry.response ?? ""}` : "0|";
});
- const [speech] = (0, import_react29.useState)(() => actions.speech());
- const [phase, setPhase] = (0, import_react29.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
- const [catalog, setCatalog] = (0, import_react29.useState)(null);
- const [heard, setHeard] = (0, import_react29.useState)("");
- const [said, setSaid] = (0, import_react29.useState)("");
- const [note, setNote] = (0, import_react29.useState)("");
- const [serviceLevel, setServiceLevel] = (0, import_react29.useState)(0);
- const [cycle, setCycle] = (0, import_react29.useState)(0);
- const waiting = (0, import_react29.useRef)(null);
- const reply = (0, import_react29.useRef)(lastReply);
+ const [speech] = (0, import_react30.useState)(() => actions.speech());
+ const [phase, setPhase] = (0, import_react30.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
+ const [catalog, setCatalog] = (0, import_react30.useState)(null);
+ const [heard, setHeard] = (0, import_react30.useState)("");
+ const [said, setSaid] = (0, import_react30.useState)("");
+ const [note, setNote] = (0, import_react30.useState)("");
+ const [serviceLevel, setServiceLevel] = (0, import_react30.useState)(0);
+ const [cycle, setCycle] = (0, import_react30.useState)(0);
+ const waiting = (0, import_react30.useRef)(null);
+ const reply = (0, import_react30.useRef)(lastReply);
reply.current = lastReply;
- const turn = (0, import_react29.useRef)(0);
+ const turn = (0, import_react30.useRef)(0);
const paneLeft = usePaneLeft();
const close = () => {
turn.current += 1;
@@ -12173,12 +12580,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
dictation.stop();
submit(text);
});
- (0, import_react29.useEffect)(() => {
+ (0, import_react30.useEffect)(() => {
if (!note) return void 0;
const timer = setTimeout(() => setNote(""), 2600);
return () => clearTimeout(timer);
}, [note]);
- (0, import_react29.useEffect)(() => {
+ (0, import_react30.useEffect)(() => {
if (!speech) return void 0;
let live = true;
speechCatalog(speech).then(({ catalog: next, usable }) => {
@@ -12193,16 +12600,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
};
}, [speech]);
const listen = phase === "listening";
- (0, import_react29.useEffect)(() => {
+ (0, import_react30.useEffect)(() => {
if (speech || !listen || dictation.listening) return void 0;
const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => {
} }), 250);
return () => clearTimeout(timer);
}, [listen, dictation.listening]);
- (0, import_react29.useEffect)(() => {
+ (0, import_react30.useEffect)(() => {
if (phase !== "listening") dictation.stop();
}, [phase]);
- (0, import_react29.useEffect)(() => {
+ (0, import_react30.useEffect)(() => {
if (!speech || !catalog || !listen) return void 0;
const own = turn.current;
let capture = null;
@@ -12243,505 +12650,105 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
capture = null;
setServiceLevel(0);
void finish(current);
- return;
- }
- if (state === "waiting" && now - began >= limit) {
- setCycle((value) => value + 1);
- return;
- }
- frame = requestAnimationFrame(tick);
- };
- tick();
- }, (error) => {
- if (stopped) return;
- setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
- setPhase("paused");
- });
- return () => {
- stopped = true;
- cancelAnimationFrame(frame);
- capture?.cancel();
- setServiceLevel(0);
- };
- }, [speech, catalog, listen, cycle]);
- (0, import_react29.useEffect)(() => {
- if (phase !== "thinking" || running || waiting.current === null) return;
- const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
- if (count === waiting.current || !text.trim()) return;
- waiting.current = null;
- const spoken = plainSpeech(text);
- setSaid(spoken);
- setHeard("");
- if (!window.speechSynthesis || !spoken) {
- setPhase("listening");
- return;
- }
- const utterance = new SpeechSynthesisUtterance(spoken);
- utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
- utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
- utterance.onerror = utterance.onend;
- setPhase("speaking");
- window.speechSynthesis.cancel();
- window.speechSynthesis.speak(utterance);
- }, [phase, running, lastReply]);
- (0, import_react29.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
- const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
- const level = speech ? serviceLevel : micLevel;
- const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
- const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
- const togglePause = () => {
- if (phase === "paused") {
- setPhase("listening");
- return;
- }
- turn.current += 1;
- window.speechSynthesis?.cancel();
- setPhase("paused");
- };
- const openSetup = phase === "setup" ? actions.voiceSetup() : null;
- const caption = phase === "speaking" ? said : heard;
- const shownNote = note || dictation.note;
- return (0, import_react29.createElement)(
- "div",
- { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-voice-orb", style: { "--bt-level": level } },
- (0, import_react29.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
- (0, import_react29.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
- bot ? (0, import_react29.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react29.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
- ),
- (0, import_react29.createElement)("div", { className: "bt-voice-name" }, name),
- (0, import_react29.createElement)(
- "div",
- { className: "bt-voice-status", role: "status" },
- phase === "thinking" ? (0, import_react29.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react29.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
- ),
- (0, import_react29.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
- openSetup ? (0, import_react29.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
- close();
- openSetup();
- } }, t("Set up voice input")) : null,
- shownNote ? (0, import_react29.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
- ),
- (0, import_react29.createElement)(
- "div",
- { className: "bt-voice-bar" },
- (0, import_react29.createElement)("button", {
- type: "button",
- className: "bt-voice-btn",
- "aria-pressed": phase === "paused",
- disabled: blocked,
- "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
- onClick: togglePause
- }, (0, import_react29.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react29.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
- (0, import_react29.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react29.createElement)(CloseIcon))
- )
- );
-}
-
-// src/client/group-settings.js
-var import_react30 = require("react");
-var ROOM_MODES = [
- ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
- ["everyone", "Everyone", "Every member answers in turn, admin first."]
-];
-var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
-function faceRoomCount(room2, roster) {
- const people2 = facePeople(room2, roster).length;
- const seats = faceSeats(room2, roster).length;
- const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
- const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
- return `${persons} · ${bots}`;
-}
-var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
-var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
-function FacePersonRow({ person, onOpenBot, lead, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
- const bots = person.seats.flatMap((seat, index) => {
- const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
- const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
- return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
- });
- return (0, import_react30.createElement)(
- "div",
- { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who bt-face-person" },
- (0, import_react30.createElement)(PersonMark, { size, name: person.name }),
- (0, import_react30.createElement)(
- "span",
- { className: "bt-face-person-text" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
- (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots)
- )
- )
- );
-}
-function FaceLead({ room: room2, roster, pick: pick2 }) {
- const lead = room2.face?.lead;
- const [open, setOpen] = (0, import_react30.useState)(false);
- const [busy, setBusy] = (0, import_react30.useState)(false);
- const [error, setError] = (0, import_react30.useState)("");
- const seats = faceSeats(room2, roster);
- const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
- if (!current) return null;
- const canPick = lead.canPick === true && typeof pick2 === "function";
- const choose = async (handle) => {
- setBusy(true);
- setError("");
- try {
- await pick2(handle);
- setOpen(false);
- } catch (err) {
- setError(err?.message || t("Could not change the room lead."));
- } finally {
- setBusy(false);
- }
- };
- return (0, import_react30.createElement)(
- "div",
- { className: "bt-face-lead", "data-flybot": "face-lead" },
- (0, import_react30.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row" },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
- ),
- canPick ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
- ),
- open && canPick ? seats.map((seat) => (0, import_react30.createElement)(
- "div",
- { key: seat.key, className: "bt-set-row" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
- seat === current ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
- )) : null,
- open && canPick && lead.picked ? (0, import_react30.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
- canPick || !lead.supported ? null : (0, import_react30.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
- error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
- );
-}
-function roomSubtitle(room2, roster) {
- if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
- const admin = roster.byId[room2.admin];
- return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
-}
-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
- const [adding, setAdding] = (0, import_react30.useState)(false);
- const [confirming, setConfirming] = (0, import_react30.useState)(false);
- (0, import_react30.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 4e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- const live = room2.presentedParticipants ?? room2.participants ?? [];
- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
- const face = room2.origin === "face-to-face";
- const people2 = face ? facePeople(room2, roster) : [];
- const shared2 = humans.length > 1;
- const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
- const outside = roster.bots.filter((bot) => {
- if (bot.remote) return false;
- if (room2.members.includes(bot.id)) return false;
- if (shared2) return chiefs.has(bot.id);
- return true;
- });
- const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
- const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
- const admin = roster.byId[room2.admin];
- const mode = shownMode(room2, roster);
- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
- const update = (patch) => run(actions.updateRoom(room2.id, patch));
- const openBot = (id) => {
- actions.closeSettings();
- actions.openSession(id);
- };
- const remove = () => {
- if (!confirming) {
- setConfirming(true);
+ return;
+ }
+ if (state === "waiting" && now - began >= limit) {
+ setCycle((value) => value + 1);
+ return;
+ }
+ frame = requestAnimationFrame(tick);
+ };
+ tick();
+ }, (error) => {
+ if (stopped) return;
+ setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
+ setPhase("paused");
+ });
+ return () => {
+ stopped = true;
+ cancelAnimationFrame(frame);
+ capture?.cancel();
+ setServiceLevel(0);
+ };
+ }, [speech, catalog, listen, cycle]);
+ (0, import_react30.useEffect)(() => {
+ if (phase !== "thinking" || running || waiting.current === null) return;
+ const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
+ if (count === waiting.current || !text.trim()) return;
+ waiting.current = null;
+ const spoken = plainSpeech(text);
+ setSaid(spoken);
+ setHeard("");
+ if (!window.speechSynthesis || !spoken) {
+ setPhase("listening");
return;
}
- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
- if (done) onDeleted?.();
- });
+ const utterance = new SpeechSynthesisUtterance(spoken);
+ utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
+ utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
+ utterance.onerror = utterance.onend;
+ setPhase("speaking");
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ }, [phase, running, lastReply]);
+ (0, import_react30.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
+ const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
+ const level = speech ? serviceLevel : micLevel;
+ const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
+ const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
+ const togglePause = () => {
+ if (phase === "paused") {
+ setPhase("listening");
+ return;
+ }
+ turn.current += 1;
+ window.speechSynthesis?.cancel();
+ setPhase("paused");
};
+ const openSetup = phase === "setup" ? actions.voiceSetup() : null;
+ const caption = phase === "speaking" ? said : heard;
+ const shownNote = note || dictation.note;
return (0, import_react30.createElement)(
"div",
- { className: "bt-bs" },
+ { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
(0, import_react30.createElement)(
"div",
- { className: "bt-bs-hero" },
- (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+ { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
(0, import_react30.createElement)(
"div",
- { className: "bt-bs-who" },
- (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name),
- (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
- )
- ),
- (0, import_react30.createElement)(
- Card,
- { title: t("Profile") },
- (0, import_react30.createElement)(
- Row,
- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
- (0, import_react30.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
+ { className: "bt-voice-orb", style: { "--bt-level": level } },
+ (0, import_react30.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
+ (0, import_react30.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
+ bot ? (0, import_react30.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
),
- (0, import_react30.createElement)(
- Row,
- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
- (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
- )
- ),
- (0, import_react30.createElement)(
- Card,
- { title: t("Who answers") },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", {
- key: id,
- type: "button",
- "aria-pressed": mode === id,
- disabled: id === "admin" && !admin,
- title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
- onClick: () => {
- if (mode !== id) void update({ mode: id });
- }
- }, t(label)))),
- (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
- )
- ),
- (0, import_react30.createElement)(
- Card,
- { title: t("Working folder") },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
- (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
- )
- ),
- face ? (0, import_react30.createElement)(
- Card,
- { title: t("Members · {count}", { count: people2.length }) },
- people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
- ) : null,
- face ? null : (0, import_react30.createElement)(
- Card,
- { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
- humans.map((person) => (0, import_react30.createElement)(
- "div",
- { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
- (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")),
- person.role === "admin" ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
- ),
- humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
- )),
- members.map((member) => {
- const leads = room2.admin === member.id;
- return (0, import_react30.createElement)(
- "div",
- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "button",
- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
- (0, import_react30.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, member.name),
- leads ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
- (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
- ),
- leads ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
- members.length > 2 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
- );
- }),
- remotes.map((person) => (0, import_react30.createElement)(
- "div",
- { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who" },
- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
- // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
- // labelled as such; a Bot with no Bot Social identity can never be written to at all.
- room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
- ),
- // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
- room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
- )),
- room2.origin === "face-to-face" ? null : adding ? [
- ...outsidePeople.map((person) => (0, import_react30.createElement)(
- "button",
- { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
- } },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
- )),
- ...outside.map((bot) => (0, import_react30.createElement)(
- "button",
- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ add: [bot.id] });
- } },
- (0, import_react30.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, bot.name)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add"))
- )),
- ...outsideRemotes.map((bot) => (0, import_react30.createElement)(
- "button",
- { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
- } },
- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
- )),
- (0, import_react30.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_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null,
- (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)(
- "span",
- { className: "bt-set-hint" },
- // The host checks for two members on every change, even a rename.
- members.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")
- )),
- // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
- // the note on who leads, so that note stays with the members it is about (#892).
- room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
- ),
- (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
- (0, import_react30.createElement)(
- Card,
- { title: t("Sidebar") },
- (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
- (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
- ),
- (0, import_react30.createElement)(
- Card,
- { title: t("More") },
- (0, import_react30.createElement)(
- "button",
- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
- ),
- (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
- )
- )
- );
-}
-function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
- const [admin, setAdmin] = (0, import_react30.useState)(null);
- const [members, setMembers] = (0, import_react30.useState)([]);
- const [name, setName] = (0, import_react30.useState)("");
- const [busy, setBusy] = (0, import_react30.useState)(false);
- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
- const pickAdmin = (id) => {
- setAdmin(id);
- setMembers((list) => list.filter((item) => item !== id));
- };
- const create = async () => {
- if (busy || admin === null || members.length === 0) return;
- setBusy(true);
- let room2;
- const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
- room2 = value;
- }));
- if (done && room2) onCreated(room2);
- else setBusy(false);
- };
- const chip = (bot, on, onClick, role) => (0, import_react30.createElement)("button", {
- key: bot.id,
- type: "button",
- role,
- className: "bt-ng-chip",
- "aria-checked": on,
- onClick
- }, (0, import_react30.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react30.createElement)(AdminStar) : null);
- return (0, import_react30.createElement)(
- "div",
- { className: "bt-set-card bt-ng" },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react30.createElement)(AdminStar), t("Choose the group admin"))),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
- )
- ),
- admin === null ? null : (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
+ (0, import_react30.createElement)("div", { className: "bt-voice-name" }, name),
(0, import_react30.createElement)(
"div",
- { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
- roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
- )
- ),
- admin === null || members.length === 0 ? null : (0, import_react30.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-ng-step" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
- (0, import_react30.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
+ { className: "bt-voice-status", role: "status" },
+ phase === "thinking" ? (0, import_react30.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react30.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
+ ),
+ (0, import_react30.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
+ openSetup ? (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
+ close();
+ openSetup();
+ } }, t("Set up voice input")) : null,
+ shownNote ? (0, import_react30.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
),
(0, import_react30.createElement)(
"div",
- { className: "bt-set-row bt-task-foot" },
- (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
- (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
+ { className: "bt-voice-bar" },
+ (0, import_react30.createElement)("button", {
+ type: "button",
+ className: "bt-voice-btn",
+ "aria-pressed": phase === "paused",
+ disabled: blocked,
+ "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
+ onClick: togglePause
+ }, (0, import_react30.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react30.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
+ (0, import_react30.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react30.createElement)(CloseIcon))
)
);
}
-var GROUP_SETTINGS_CSS = `
-.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
-.bt-ng-step{animation:bt-veil-in .22s ease-out}
-.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
-.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
-.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
-.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
-.bt-ng-chip:active{scale:.96}
-.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
-.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
-.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
-.bt-gs-member{gap:8px}
-.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
-.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
-.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
-.bt-face-person{cursor:default}
-.bt-face-person:hover .bt-row-title{text-decoration:none}
-.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
-.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
-.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
-button.bt-face-bot{cursor:pointer}
-button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
-.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
-.bt-bs-stack .bt-seg{align-self:stretch}
-@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
-`;
// src/client/usage-data.js
var HOUR = 0;
@@ -14508,7 +14515,7 @@ function GroupsPage({ roster, actions, run }) {
{ className: "bt-set-bot" },
(0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
(0, import_react35.createElement)("span", { className: "bt-row-title" }, room2.name),
- (0, import_react35.createElement)("span", { className: "bt-tag" }, t("{count} members", { count: room2.members.length })),
+ (0, import_react35.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })),
admin ? (0, import_react35.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react35.createElement)(AdminStar), admin.name) : null,
room2.hidden ? (0, import_react35.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
),
diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js
index 95cfcacbb..f047f625a 100644
--- a/core/src/client/group-settings.js
+++ b/core/src/client/group-settings.js
@@ -93,10 +93,19 @@ export function FaceLead({ room, roster, pick }) {
error ? h('div', { className: 'bt-set-hint', role: 'alert' }, error) : null)
}
+// Humans, this home's Bots, and remote Bots still seated. The same people the Members card lists.
+export function seatedCount(room) {
+ const live = room?.presentedParticipants ?? room?.participants ?? []
+ const humans = live.filter(p => p.kind === 'human' && p.leftSeq === undefined).length
+ const remotes = live.filter(p => p.remote && p.leftSeq === undefined).length
+ return humans + remotes + (room?.members?.length ?? 0)
+}
+
export function roomSubtitle(room, roster) {
if (room.origin === 'face-to-face') return faceRoomCount(room, roster)
const admin = roster.byId[room.admin]
- return [t('{count} members', { count: room.members.length }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ')
+ const count = seatedCount(room)
+ return [t(count === 1 ? '{count} member' : '{count} members', { count }), admin ? t('Admin {name}', { name: admin.name }) : t('No admin')].join(' · ')
}
// A group chat's settings as grouped cards: its name and notice, who answers, its
@@ -214,7 +223,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
: null,
h('div', { className: 'bt-set-row bt-gs-note' }, h('span', { className: 'bt-set-hint' },
// The host checks for two members on every change, even a rename.
- members.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
+ members.length + humans.length + remotes.length < 2 ? t('Add a Bot first: a group chat needs at least two.')
: admin ? t('{name} leads this group: it can call members with @Name, change the group, and post here.', { name: admin.name })
: t('No admin yet. An admin can call members with @Name, change the group, and post here.'))),
// #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
diff --git a/core/src/client/palette.js b/core/src/client/palette.js
index 0bd9d0d92..c8c5f6b95 100644
--- a/core/src/client/palette.js
+++ b/core/src/client/palette.js
@@ -8,6 +8,7 @@ import { useEscape } from './overlay-hooks.js'
import { QUICK_EDGES, useFlip, useGlide } from './motion.js'
import { t } from './i18n.js'
import { isListedRemoteChat, pickerBotLabel } from '../shared/remote-label.js'
+import { seatedCount } from './group-settings.js'
// Search is a command palette: a 560px dialog, a search header, and 49px
// rows (24px avatar, title, subtitle). Bots and groups match on name and role; past
@@ -56,7 +57,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) {
run: async () => { if (bot.hidden) await actions.setFlags(bot.id, { hidden: false }); actions.openSession(bot.id) },
})),
...roster.rooms.filter(room => matchesName(room.name)).map(room => ({
- key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t(room.members.length === 1 ? '{count} member' : '{count} members', { count: room.members.length }),
+ key: room.id, avatar: h(RoomAvatar, { room, roster, size: 24 }), title: room.name, subtitle: t(seatedCount(room) === 1 ? '{count} member' : '{count} members', { count: seatedCount(room) }),
badge: room.hidden ? t('Hidden') : undefined,
run: async () => { if (room.hidden) await actions.setFlags(room.id, { hidden: false }); actions.openSession(room.id) },
})),
diff --git a/core/src/client/settings.js b/core/src/client/settings.js
index 310275a3c..b072ebaaf 100644
--- a/core/src/client/settings.js
+++ b/core/src/client/settings.js
@@ -9,7 +9,7 @@ import { SettingsPageSlot, useSettingsPages, call } from './flybot-slots.js'
import { useEscape } from './overlay-hooks.js'
import { BotSettingsForm, SwitchRow } from './bot-settings.js'
import { Segmented, useGlide } from './motion.js'
-import { GroupSettingsForm, NewGroupForm } from './group-settings.js'
+import { GroupSettingsForm, NewGroupForm, seatedCount } from './group-settings.js'
import { CreateBot } from './create-bot.js'
import { KeyIcon, SecretsPage } from './secret-card.js'
import { UsagePage } from './usage-page.js'
@@ -158,7 +158,7 @@ function GroupsPage({ roster, actions, run }) {
h('span', { className: 'bt-set-bot' },
h(RoomAvatar, { room, roster, size: 28 }),
h('span', { className: 'bt-row-title' }, room.name),
- h('span', { className: 'bt-tag' }, t('{count} members', { count: room.members.length })),
+ h('span', { className: 'bt-tag' }, t(seatedCount(room) === 1 ? '{count} member' : '{count} members', { count: seatedCount(room) })),
admin ? h('span', { className: 'bt-tag bt-tag-admin' }, h(AdminStar), admin.name) : null,
room.hidden ? h('span', { className: 'bt-tag' }, t('Hidden')) : null),
h('span', { className: 'bt-chevron' }, h(ChevronRightIcon)))
diff --git a/core/src/host/groups.js b/core/src/host/groups.js
index c7856f41d..27ff99dd1 100644
--- a/core/src/host/groups.js
+++ b/core/src/host/groups.js
@@ -28,7 +28,7 @@ import { failureLine } from './turns.js'
import { currentParticipants, isFaceRoom } from './room-membership.js'
import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, faceRoster, isFaceLead } from './face-lead.js'
import { faceMentions } from './face-mentions.js'
-import { cleanName, stripSessionChips } from './delivery.js'
+import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js'
import { createDshRoomParticipant } from './room-participant.js'
const ROOM_LOG_MAX = 60
@@ -624,7 +624,7 @@ export function install(rt) {
// stopped is an event of the round.
const event = item => item.failed || item.resumed || item.stopped || item.notice || item.botId === 'face-notice'
// A post a running round already showed and logged when it arrived (#455) is not logged again.
- for (const post of posts.filter(item => !event(item) && !item.shown)) {
+ for (const post of posts.filter(item => !event(item) && !item.shown && !item.displayOnly)) {
// Image readings go into the log only; `@` names are picked from what the user typed.
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
// A face-room line the send path already logged and sent is not logged or sent again (#287).
@@ -738,6 +738,10 @@ export function install(rt) {
if (typeof reply !== 'string' || reply.trim() === '') return reply
const posted = appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId, authorId: botId, authorKind: 'bot', text: reply }) ?? { who: member.name, botId, text: reply })
void rt.save().catch(() => {})
+ // The reply is in the log and on its way back. The pane is this room's Session, which that append does not touch.
+ await rt.showSharedLine?.(room, {
+ who: member.name, text: reply, local: true, senderSessionId: botId, remoteSeq: posted.seq, via: 'local-reply',
+ }).catch(error => rt.warn('ds-bot: a reply in %s was not shown: %s', room.name, error))
return { seq: posted.seq, text: reply }
}
@@ -843,6 +847,8 @@ export function install(rt) {
}
function openingSpeakers(room, opener) {
+ // A line already in the log, posted so the pane can draw it. `@` in it is not a new call.
+ if (opener.displayOnly === true) return []
const everyone = room.members.map(rt.botOf).filter(Boolean)
const members = everyone.filter(bot => bot.id !== opener.botId)
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
@@ -914,6 +920,15 @@ export function install(rt) {
function showInRound(room, sender, text, extra) {
const round = [...rt.roomProgress.values()].findLast(entry => entry.roomId === room.id)
if (round === undefined || text === '') return false
+ // Already logged by the share path. Draw it in this round; the later followup is marked shown.
+ if (extra.displayOnly === true) {
+ if (extra.remote?.handle) {
+ round.replies.push({ kind: 'remote', remote: true, botId: sender.id, handle: extra.remote.handle, name: sender.name, text, time: Date.now() })
+ } else if (rt.botOf(sender.id)) {
+ round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now() })
+ }
+ return true
+ }
if (extra.human) {
round.replies.push(personReply(extra.human, text))
return true
@@ -971,6 +986,23 @@ export function install(rt) {
}
rt.postToRoom = postToRoom
+ // A shared-room or ticket-seat line is already in room.log. Put it in the Session so the pane
+ // draws it, without logging it again (that would fan it out) and without opening a round.
+ rt.showSharedLine = async function showSharedLine(room, spec = {}) {
+ const text = unwrapUntrusted(String(spec.text ?? '')).trim()
+ if (!room || text === '') return
+ const local = spec.local === true
+ const handle = typeof spec.remote?.handle === 'string' ? spec.remote.handle : undefined
+ const sender = {
+ id: spec.senderSessionId || handle || 'shared',
+ name: cleanName(spec.who || 'member'),
+ }
+ await postToRoom(room, sender, text, {
+ displayOnly: true,
+ ...(local || !handle ? {} : { remote: { handle, ...(typeof spec.remote.typedBy === 'string' && spec.remote.typedBy ? { typedBy: spec.remote.typedBy } : {}) } }),
+ })
+ }
+
// A member the round moved on from (it went on working, went quiet or waited for an
// approval) posts what its turn ended with. The member that asked for it and anyone
// it names answer; past the late round limit nobody does, so late posts cannot keep a
diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js
index c31b12bef..cabb11036 100644
--- a/core/src/host/room-membership.js
+++ b/core/src/host/room-membership.js
@@ -36,7 +36,7 @@
import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
-import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
+import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
@@ -1061,6 +1061,99 @@ export function install(rt) {
return text
}
+ // Chat lines the join copies to a guest. Events (joined, left, notices) are not messages.
+ // A handle is this home's Bot Social identity for a local Bot, or the seat a remote line came from.
+ function shareHistory(room) {
+ const lines = (room.log ?? []).filter(line => !line.kind && line.text)
+ return lines.slice(-HISTORY_MAX).map((line) => {
+ const botId = line.authorKind === 'bot' ? (line.authorId ?? line.botId) : undefined
+ const carried = typeof line.via === 'string' && line.via.startsWith('@') ? line.via : undefined
+ const claimed = typeof line.authorId === 'string' && line.authorId.startsWith('@') ? line.authorId : undefined
+ const own = botId ? state.bots?.[botId]?.fields?.social?.member?.handle : undefined
+ const handle = carried || claimed || own
+ return {
+ who: cleanName(line.who || 'member'),
+ text: seatText(line).slice(0, HISTORY_TEXT),
+ seq: line.seq ?? 0,
+ ...(line.authorKind === 'human' || line.authorKind === 'bot' ? { authorKind: line.authorKind } : {}),
+ ...(line.authorId ? { authorId: String(line.authorId).slice(0, 120) } : {}),
+ ...(typeof handle === 'string' && handle.startsWith('@') ? { handle } : {}),
+ }
+ })
+ }
+
+ // One show per remote line. A redelivery still appends (a second turn is already refused);
+ // it must not draw a second bubble.
+ const SHOWN_KEPT = 200
+ function claimShow(room, key) {
+ if (!key) return true
+ room.shownShare ??= []
+ if (room.shownShare.includes(key)) return false
+ room.shownShare = [...room.shownShare, key].slice(-SHOWN_KEPT)
+ return true
+ }
+
+ // The conversation pane is the room Session. `room.log` only feeds the sidebar preview.
+ // Post the line there once, display-only, so it is not logged again and opens no Bot round.
+ async function showShareLine(room, spec) {
+ if (!room || typeof rt.showSharedLine !== 'function') return
+ const text = unwrapUntrusted(String(spec.text ?? '')).trim()
+ if (!text) return
+ const seq = Number.isFinite(Number(spec.remoteSeq)) ? Number(spec.remoteSeq) : undefined
+ const key = seq === undefined ? undefined : `${spec.via || 'line'}:${seq}`
+ if (!claimShow(room, key)) return
+ try {
+ await rt.showSharedLine(room, { ...spec, text })
+ } catch (error) {
+ rt.warn?.('ds-bot: a shared line was not shown in %s: %s', room.name, error)
+ }
+ }
+
+ // History is stranger-written, like the notice: only this mirror's room, only its inviter.
+ function historyOf(data, room, event) {
+ if (data?.op !== 'join' || !Array.isArray(data.history)) return undefined
+ const bound = room?.sharedFrom ?? {}
+ const same = (a, b) => {
+ const left = String(a ?? '').trim().toLowerCase()
+ return left !== '' && left === String(b ?? '').trim().toLowerCase()
+ }
+ if (bound.remoteRoomId && !same(data.roomId, bound.remoteRoomId)) return undefined
+ if (bound.inviterHome && !same(event?.fromHome, bound.inviterHome)) return undefined
+ if (bound.inviterHandle && !same(event?.fromHandle, bound.inviterHandle)) return undefined
+ return data.history.slice(-HISTORY_MAX).filter(entry => entry && entry.who && entry.text)
+ }
+
+ // Copy those lines into a mirror that has no chat lines yet. No turn: they were already said.
+ async function applyShareHistory(room, entries, event, data) {
+ if (!room || !Array.isArray(entries) || entries.length === 0) return
+ if ((room.log ?? []).some(line => !line.kind && line.text)) return
+ const fromHandle = String(event?.fromHandle ?? '')
+ for (const entry of entries) {
+ const who = cleanPeerName(entry.who, 'a friend')
+ const human = entry.authorKind !== 'bot'
+ const handle = typeof entry.handle === 'string' && entry.handle.startsWith('@') ? entry.handle : fromHandle
+ const authorId = human
+ ? `history#${String(entry.authorId || 'person').slice(0, 40)}`
+ : String(handle || entry.authorId || who).slice(0, 120)
+ append(room, rt.authoredRoomLine(room, {
+ who,
+ text: String(entry.text).slice(0, HISTORY_TEXT),
+ authorId,
+ authorKind: human ? 'human' : 'bot',
+ remote: true,
+ ...(human ? {} : { botId: authorId }),
+ }))
+ await showShareLine(room, {
+ who,
+ text: entry.text,
+ remoteSeq: entry.seq,
+ via: `${data.roomId}:${fromHandle}`,
+ senderSessionId: handle || fromHandle,
+ remote: { handle: handle || fromHandle, ...(human ? { typedBy: who } : {}) },
+ })
+ }
+ }
+
// #904: one remote participant per roster entry. A roster is the whole list, so an entry it drops is a
// Bot the host no longer keeps here: it leaves this mirror's list, and nothing of it stays addressable.
function applyRoster(room, entries) {
@@ -1161,7 +1254,7 @@ export function install(rt) {
invitedHandle: remote.handle,
seq: room.seq ?? 0,
ticket: remote.ticket,
- ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}),
+ ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...seat.from,
}, 'room-share', seat.botId, room.id)
continue
@@ -1173,7 +1266,7 @@ export function install(rt) {
name: room.name,
invitedHandle: remote.handle,
seq: room.seq ?? 0,
- ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}),
+ ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...from,
}, 'room-share')
}
@@ -1679,6 +1772,14 @@ export function install(rt) {
ownerName: ownerNameSafe(seat),
kind: data.kind === 'message' ? undefined : data.kind,
}))
+ await showShareLine(room, {
+ who,
+ text: data.text || '',
+ remoteSeq: data.seq,
+ via: seat.handle,
+ senderSessionId: seat.handle,
+ remote: { handle: seat.handle, ...(human ? { typedBy: who } : {}) },
+ })
await rt.save?.()
// The other homes in the room see it too; the home it came from already has it.
void rt.fanoutRoomLine?.(room, posted)
@@ -2163,6 +2264,7 @@ export function install(rt) {
if (typeof notice === 'string') room.notice = notice
syncMembers(room)
}
+ if (data.op === 'join') await applyShareHistory(room, historyOf(data, room, event), event, data)
let called = []
if (data.op === 'line') {
const who = cleanPeerName(data.who || transportName, transportName)
@@ -2182,6 +2284,15 @@ export function install(rt) {
...(author.authorKind === 'bot' ? { botId: author.authorId } : {}),
kind: data.kind === 'message' ? undefined : data.kind,
}))
+ const speaker = author.authorKind === 'bot' && String(author.authorId).startsWith('@') ? author.authorId : fromHandle
+ await showShareLine(room, {
+ who,
+ text: data.text || '',
+ remoteSeq: data.seq,
+ via: `${data.roomId}:${fromHandle}`,
+ senderSessionId: speaker || fromHandle,
+ remote: { handle: speaker || fromHandle, ...(author.authorKind !== 'bot' ? { typedBy: who } : {}) },
+ })
// #825: the line names Bots, and one of them is mine. The remote home resolved the call where
// mentions are resolved, so this side never reads @names out of the text.
const named = localCalls(room, data.calls)
diff --git a/core/src/host/turns.js b/core/src/host/turns.js
index b5eded5f8..21c0adaa3 100644
--- a/core/src/host/turns.js
+++ b/core/src/host/turns.js
@@ -433,6 +433,8 @@ export function install(rt) {
...(source.human ? { human: source.human } : {}),
// Shown (and logged) by the round that was running when it arrived (groups.js, #455).
...(source.shown === true ? { shown: true } : {}),
+ // Already in the room log; the relay must not append it again or call the Bots it names.
+ ...(source.displayOnly === true ? { displayOnly: true } : {}),
// The round that was running already called the members this line names (#932).
...(source.called === true ? { called: true } : {}),
...(source.plain ? { plain: true } : {}),
diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js
index 818bde3f6..65854f144 100644
--- a/core/src/shared/room-share.js
+++ b/core/src/shared/room-share.js
@@ -19,6 +19,10 @@ function clip(value, max = 60) {
export const ROSTER_MAX = 32
// The group notice's own limit in the room editor (core/src/client/group-settings.js).
export const NOTICE_MAX = 2000
+// Recent chat lines a join may carry so a guest's mirror is not empty. Absent means don't
+// backfill (an older host); an empty list means the room had no chat lines.
+export const HISTORY_MAX = 20
+export const HISTORY_TEXT = 2000
// #904: a handle is unique only inside its home, so an entry carries the home it belongs to: two homes
// can keep Bots with the same local name, and a guest has to tell them apart when it attributes a line.
@@ -35,6 +39,20 @@ function rosterEntry(entry = {}) {
return out
}
+// A chat line the join copies across. Text keeps its line breaks; other control characters go.
+function historyLine(entry = {}) {
+ const who = clip(entry.who)
+ const text = String(entry.text ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').trim().slice(0, HISTORY_TEXT)
+ if (!who || !text) return undefined
+ const out = { who, text, seq: Number.isFinite(Number(entry.seq)) ? Math.max(0, Math.floor(Number(entry.seq))) : 0 }
+ if (entry.authorKind === 'human' || entry.authorKind === 'bot') out.authorKind = entry.authorKind
+ const authorId = clip(entry.authorId, 120)
+ if (authorId) out.authorId = authorId
+ const handle = clip(entry.handle, 120)
+ if (/^@[A-Za-z0-9._-]{1,120}$/.test(handle)) out.handle = handle
+ return out
+}
+
export function encodeRoomShare(payload = {}) {
const body = {
v: 1,
@@ -82,6 +100,10 @@ export function encodeRoomShare(payload = {}) {
// empty. It is text the room displays, never authority. A present notice is encoded even when empty:
// empty means the host cleared it, while an absent field means "change nothing" (an older host).
if (body.op === 'join' && typeof payload.notice === 'string') body.notice = clip(payload.notice, NOTICE_MAX)
+ // Same rule as the roster: a present list is encoded even when empty, an absent field changes nothing.
+ if (body.op === 'join' && Array.isArray(payload.history)) {
+ body.history = payload.history.map(historyLine).filter(Boolean).slice(-HISTORY_MAX)
+ }
return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
}
diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js
index d5c808a55..8d1378f4a 100644
--- a/core/test/face-people.test.js
+++ b/core/test/face-people.test.js
@@ -65,6 +65,20 @@ test('another person\'s face-room line is their own received bubble with name an
assert.equal(find(drawn, node => node.props?.className === 'bt-ubody').length, 0, 'not the sent-bubble style')
})
+test('a shared room\'s subtitle counts remote Bots that are still in the room', () => {
+ const roster = { byId: { 'chief-b': { id: 'chief-b', name: 'Chief' } } }
+ const room = {
+ origin: 'inbound-share', admin: 'chief-b', members: ['chief-b'],
+ participants: [
+ { kind: 'human', id: 'owner', displayName: 'You' },
+ { kind: 'bot', id: 'chief-b' },
+ { kind: 'bot', remote: true, handle: '@chief.alice.home', displayName: 'Chief' },
+ { kind: 'bot', remote: true, handle: '@writer.alice.home', displayName: 'Writer', leftSeq: 3 },
+ ],
+ }
+ assert.equal(roomSubtitle(room, roster), '3 members · Admin Chief')
+})
+
test('a face room lists people, each with the Bot they brought, named one way', () => {
const roster = {
self: { memberId: 'm1', displayName: 'SelfB0333' },
diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js
index 230fb7633..66025fb67 100644
--- a/core/test/room-ticket-seat.test.js
+++ b/core/test/room-ticket-seat.test.js
@@ -500,6 +500,48 @@ test('#899: a room-only identity is promoted for the link it makes and the link
assert.equal((await b.rt.previewLocalInvite(invite.url)).joined, true)
})
+test('a ticket seat shows the other home\'s lines in the room session, including lines from before the join', async (t) => {
+ const { a, b, log, settle } = await twoHomes(t)
+ const shown = { a: [], b: [] }
+ for (const home of [a, b]) {
+ const key = home === a ? 'a' : 'b'
+ home.rt.showSharedLine = async (room, spec) => { shown[key].push({ roomId: room.id, ...spec }) }
+ }
+ const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' })
+ await a.rt.postRoomMessage('war', { text: 'Before you joined', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' })
+ await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' })
+ await settle()
+
+ const mirror = guestRoomOf(b.rt)
+ assert.ok(mirror, 'the join projected the room')
+ assert.ok(mirror.log.some(entry => String(entry.text).includes('Before you joined')), 'lines from before the join are in the guest log')
+ assert.ok(shown.b.some(item => item.roomId === mirror.id && item.text.includes('Before you joined') && item.remote?.typedBy === 'Alice'), 'and in the guest session')
+ const joins = log.filter(entry => parseRoomShare(entry.text)?.op === 'join')
+ assert.ok(joins.some(entry => parseRoomShare(entry.text).history?.some(line => line.text.includes('Before you joined'))), 'the join carries that history')
+
+ const room = a.rt.state.rooms.war
+ const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' })
+ await a.rt.fanoutRoomLine(room, line)
+ await settle()
+ const guestShows = () => shown.b.filter(item => item.text.includes('Ship on Friday?'))
+ assert.equal(guestShows().length, 1, 'the host line is shown once on the guest')
+ assert.equal(guestShows()[0].remote.typedBy, 'Alice')
+ assert.equal(guestShows()[0].remote.handle, '@chief.alice.home')
+
+ const reply = { who: 'Chief', botId: 'chief-b', authorId: 'chief-b', authorKind: 'bot', text: 'Friday works.', seq: (mirror.seq ?? 0) + 1 }
+ await b.rt.fanoutRoomLine(mirror, reply)
+ await settle()
+ const hostShows = shown.a.filter(item => item.text.includes('Friday works.'))
+ assert.equal(hostShows.length, 1, 'the guest line is shown once on the host')
+ assert.equal(hostShows[0].roomId, 'war')
+ assert.equal(hostShows[0].remote.handle, '@chief.bob.home')
+ assert.equal(hostShows[0].remote.typedBy, undefined, 'a Bot line is the Bot\'s, not a person\'s')
+
+ await a.rt.fanoutRoomLine(room, line)
+ await settle()
+ assert.equal(guestShows().length, 1, 'a redelivery does not show the host line again')
+})
+
test('#899: a link minted on a room-only Bot is promoted when the host wires the server in', async (t) => {
const { a } = await twoHomes(t)
const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' })
diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js
index 8665dbb7c..bfafb392d 100644
--- a/core/test/rounds.test.js
+++ b/core/test/rounds.test.js
@@ -182,6 +182,57 @@ test('a message the composer queues mid-round joins the round and leaves the Ses
assert.deepEqual(inboxOf(room.id).nextTurn, [])
})
+test('a display-only shared line is not logged again and calls no Bot', async (t) => {
+ const { call, emit, relay, stop, sent, rt } = start()
+ t.after(stop)
+ const room = await team(call, sent)
+ const live = rt.state.rooms[room.id]
+ live.log ??= []
+ live.seq ??= 0
+ const before = live.log.length
+ sent.length = 0
+ await rt.showSharedLine(live, {
+ who: 'Alice', text: 'hello @Writer', senderSessionId: '@chief.alice.home', remoteSeq: 4, via: 'war:@chief.alice.home',
+ remote: { handle: '@chief.alice.home', typedBy: 'Alice' },
+ })
+ const posted = sent.find(entry => entry.sessionId === room.id && entry.message.source?.displayOnly === true)
+ assert.ok(posted, 'the line is a followup in the room session')
+ assert.equal(posted.message.source.remote.handle, '@chief.alice.home')
+ assert.equal(posted.message.source.remote.typedBy, 'Alice')
+ assert.match(posted.message.content[0].text, /hello @Writer/)
+ assert.doesNotMatch(posted.message.content[0].text, /untrusted-message/)
+ await emit(room.id, { type: 'user/message', data: { source: posted.message.source, content: posted.message.content } })
+ const result = JSON.parse(await relay(room.id, 'relay-display'))
+ assert.deepEqual(result.replies, [], 'a display-only line opens no round')
+ assert.equal(sent.filter(entry => entry.message.source?.role === 'room').length, 0, 'no Bot is called, not even one the text names')
+ assert.equal(live.log.length, before, 'the line was already logged; the session post does not log it again')
+})
+
+test('a guest Bot\'s reply in a projected room is shown in that room\'s session', async (t) => {
+ const { call, answer, stop, sent, rt } = start()
+ t.after(stop)
+ const room = await team(call, sent)
+ const chief = rt.state.mainBotIds[0]
+ const live = rt.state.rooms[room.id]
+ live.log ??= []
+ live.seq ??= 0
+ live.seen ??= {}
+ live.origin = 'inbound-share'
+ live.sharedFrom = { inviterName: 'Alice', inviterHandle: '@chief.alice.home', remoteRoomId: 'war', localBotId: chief, ticket: 'Tkt0001AbCdEfGhIjKlMnO' }
+ sent.length = 0
+ const running = rt.startInboundTurn({ room: live, botId: chief, seq: 1, who: 'Alice' })
+ const prompt = await turnFor(sent)
+ await answer(prompt.sessionId, prompt.message.source.exchangeId, 'I got it.', 1)
+ const result = await running
+ assert.match(result.text, /I got it/)
+ const shown = sent.find(entry => entry.sessionId === room.id && entry.message.source?.displayOnly === true)
+ assert.ok(shown, 'the reply is in the room session')
+ assert.match(shown.message.content[0].text, /I got it/)
+ assert.equal(shown.message.source.remote, undefined, 'it is this home\'s own Bot')
+ assert.equal(shown.message.source.senderSessionId, chief)
+ assert.equal(live.log.filter(line => String(line.text).includes('I got it.')).length, 1, 'the reply is logged once')
+})
+
test('#180: another member\'s line is a bubble from that person and starts no Bot turn', async (t) => {
const { call, emit, relay, stop, sent } = start()
t.after(stop)
diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md
index e3a88a65e..699be6a7e 100644
--- a/docs/subsystems/core-client.md
+++ b/docs/subsystems/core-client.md
@@ -168,7 +168,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- In a face room a bubble names its speaker the same way (#457): my Bots' lines `Chief (SelfB)` (`cells.js` `GroupMessage` via `faceBotLabel`), a friend's Bot `Researcher (SelfA)` (`remoteSpeaker`), name only, with the handle as the tooltip instead of a globe and handle line. My own typed line is mine, not my Bot's: it carries my person mark and name (`data-face-mine`) and is never drawn held, so it does not change colour while a Bot's reply waits (`user-message.js`). A `@Chief (SelfA)` chip stays bound to that Bot after it leaves: `faceMentionables` also matches the left seats (after the live ones), so the name never falls through to my own Chief.
- **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). For a friend's chat in the roster, `friendBotTitle(bot)` is that one call (#498), on both homes: the sidebar row (one string, no muted owner part), chat header, tab title, composer placeholder, row tooltip and the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row); in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` give the same format (`Chief (Bob)`, my own Bot `Chief (SelfB)` with my name, #457). The social plugin's classic script reads the same functions from `globalThis.__flybot__.names` (`friendBotTitle`, `friendBotOwner`, set in `flybot-slots.js` `registry()`). Approval cards name the receiving person instead (`Chief → SelfB1233`, #418). `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422).
- **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`.
-- A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306).
+- A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). Any other group's subtitle, command-palette row and Groups-list tag use `seatedCount`: humans, this home's Bots, and remote Bots still in the room — the same people the Members card lists — so a shared room does not read as only the Bots this home brought. A remote that left is not counted. The "Add a Bot first" note uses that same total.
- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305).
- A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `installHeaderEffects` (`header-effects.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `