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 `