diff --git a/changelog.d/1566-inbox-hidden-failed-turn.fixed.md b/changelog.d/1566-inbox-hidden-failed-turn.fixed.md new file mode 100644 index 000000000..7a9d80c0b --- /dev/null +++ b/changelog.d/1566-inbox-hidden-failed-turn.fixed.md @@ -0,0 +1 @@ +A failed turn in a hidden Bot's own chat no longer leaves a card in the Inbox that cannot be opened or dismissed; a failure in a group is still listed, because the group's row opens there, and *In progress* is unchanged. ([#1566](https://github.com/ThinkFlowLab/FlyBot/issues/1566)) diff --git a/core/client.js b/core/client.js index a1a21ab7c..8af24e1ef 100644 --- a/core/client.js +++ b/core/client.js @@ -7778,6 +7778,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now() for (const [key, entry] of lastEnded) { const bot = ownBot(roster, entry.botId); if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; + if (bot.hidden === true && entry.roomId === void 0) continue; const chat = entry.roomId ?? bot.id; const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : ""; needs.push({ diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index beafd6545..5e75347a6 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -229,6 +229,9 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date for (const [key, entry] of lastEnded) { const bot = ownBot(roster, entry.botId) if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue + // A hidden Bot has no sidebar row, so this card's only action (Open) could not show the chat + // it names (#1566). Its failure in a group still lists: the group's own row opens there. + if (bot.hidden === true && entry.roomId === undefined) continue const chat = entry.roomId ?? bot.id const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : '' needs.push({ diff --git a/core/test/inbox.test.js b/core/test/inbox.test.js index ecb997482..efd702164 100644 --- a/core/test/inbox.test.js +++ b/core/test/inbox.test.js @@ -311,6 +311,31 @@ test('a failure no longer needs me once the Bot works or waits there again', () assert.equal(freed.needs.find(card => card.id === 'turn:e3').place, 'in Ops') }) +// #1566: a hidden Bot has no sidebar row, so the failed-turn card's only action (Open) could +// never reveal the chat it names. Its failure in its own chat is left out; one in a group still +// lists, because the group's row opens it. Progress keeps counting a hidden Bot's running turn. +const hiddenRoster = { ...roster, bots: roster.bots.map(bot => (bot.id === 'b2' ? { ...bot, hidden: true } : bot)) } +hiddenRoster.byId = Object.fromEntries(hiddenRoster.bots.map(bot => [bot.id, bot])) + +test("a failed turn in a hidden Bot's own chat is not a Needs you card (#1566)", () => { + const hidden = inboxModel({ roster: hiddenRoster, work: endedWork, tasks, sessions: endedSessions, now: NOW }) + assert.ok(!ids(hidden.needs).includes('turn:e2')) + // The count badge drops it with the card, and it is not reclassified into Done. + assert.equal(needsCount(hiddenRoster, endedWork, tasks, NOW), model.needs.length) + assert.ok(!ids(hidden.done).includes('chat:b2')) + // The same snapshot with a visible Bot still lists it: the card itself is not the defect. + assert.ok(ids(endedModel.needs).includes('turn:e2')) + // A hidden Bot failing in a group still needs me: that chat is opened from the group's row. + const inGroup = { ...endedWork, ended: [...endedWork.ended, { id: 'e9', botId: 'b2', roomId: 'r1', at: NOW - 30_000, failed: 'quota' }] } + const groupModel = inboxModel({ roster: hiddenRoster, work: inGroup, tasks, sessions: endedSessions, now: NOW }) + const groupCard = groupModel.needs.find(card => card.id === 'turn:e9') + assert.ok(groupCard, 'a hidden Bot failing in a group is still listed') + assert.equal(groupCard.chat, 'r1') + // In progress keeps counting a hidden Bot's running turn; this fix only reads Needs you. + const running = inboxModel({ roster: hiddenRoster, work: { ...endedWork, bots: { ...endedWork.bots, b2: { since: NOW - 1000, tool: 'pwsh' } } }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(ids(running.progress).includes('run:b2|')) +}) + test('each card has a one-line latest activity that never repeats its title or reason', () => { const byId = Object.fromEntries([...endedModel.needs, ...endedModel.progress, ...endedModel.done].map(card => [card.id, card])) // An approval and a running turn in Ops show that chat's last line. diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8a088304f..bece607ad 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -136,7 +136,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - One **Inbox** row sits at the top of the sidebar, above the Bot list (wide and rail), with a count badge of the *Needs you* cards; it opens the inbox dialog over the conversation (owner decision 2026-10-20, option a). No new + menu entry, no new sidebar section. - Groups, in this order and with these words (the decision's "Need you" is written **Needs you**, as in the triage): **Needs you**, **In progress**, **Done**; each has a one-line empty message. Only data the client already has, no new host state: `work.approvals` (tool approvals), `pendingCount` on chats (held Bot messages), `work.bots` / `work.rooms` (running turns), the task records of `src/host/tasks.js` (`actions.tasks` → the existing `tasks` endpoint, polled every 30 s) and the Session list's `updatedAt` and last line for finished Bot chats. - Only my own Bots (not a friend's Bot, not another member's): a friend's chat appears only as a held message of mine waiting in it. Every card carries `assigner`, `owner` and `executingBot` (the first two are me in step 1) so team views can filter later. -- *Needs you*: tool approvals, held messages, tasks that are blocked, have a result ready or failed (with the reason), and failed turns (#471, `kind: 'turn'`): from `work.ended`, the same ended-turn entries the system notifications read (#353), a place's (a Bot's own chat or one group) latest ended turn when it failed, in the last 7 days, while that Bot is not running or waiting for an approval there; its title is the chat's last line (*The last turn did not finish* without the Session list), its reason the failure. A later finished turn there clears it, and that Bot chat is then not also in *Done*. **Open** only. *In progress*: running turns (with elapsed time; a turn waiting for an approval shows only in *Needs you*) and open/claimed/working tasks. *Done*: accepted or cancelled tasks and Bot chats whose last turn finished. Done and failed tasks age out after 7 days; Done shows the newest 20. +- *Needs you*: tool approvals, held messages, tasks that are blocked, have a result ready or failed (with the reason), and failed turns (#471, `kind: 'turn'`): from `work.ended`, the same ended-turn entries the system notifications read (#353), a place's (a Bot's own chat or one group) latest ended turn when it failed, in the last 7 days, while that Bot is not running or waiting for an approval there; its title is the chat's last line (*The last turn did not finish* without the Session list), its reason the failure. A later finished turn there clears it, and that Bot chat is then not also in *Done*. **Open** only. A hidden Bot's failure in its own chat is left out (#1566): it has no sidebar row, so **Open** could not show the chat the card names; one in a group still lists, because that group's row opens there. *In progress*: running turns (with elapsed time; a turn waiting for an approval shows only in *Needs you*) and open/claimed/working tasks. *Done*: accepted or cancelled tasks and Bot chats whose last turn finished. Done and failed tasks age out after 7 days; Done shows the newest 20. - A group's open question card is a *Needs you* card too (#552): `roster.questions[]` — a card a Bot asked about work handed out from that group (#550) — has the status **Needs your answer** (`input`, as a blocked task's question does), the question as its title, its detail as the reason, and *in * as its place. An ask_user card in the Bot's own chat (`roster.questions[]`, #1140) is the same kind of row and names that Bot; **Open** goes to the session that holds the card (the Bot's chat, not the group the task was handed out from). Both rows show the card's options and a short answer. One option sends at once; a multi-select toggles and Submit sends the set, with the short answer. Either call is `actions.answerQuestion`, the same answer the chat card sends, and the row leaves. What was typed or toggled stays when the Inbox re-renders. A task that is waiting on input shows the same short answer and calls `actions.answerTaskInput` (`task-answer-input`), so the owner is not sent to a group that has no card as the only way to answer. A card for a removed group, and one from another member's Bot, add none. - Each card has exactly one status, as a colored pill with an icon and a word (never color alone), the Bot, where and when, and **Open** (closes the inbox, opens the chat). *In progress* and *Done* keep the title on one line, with the full text on hover. A *Needs you* card (`data-status` `approval`, `held`, `input`, `result` or `failed`) shows its title and reason in full — they wrap, including line breaks, and the phone layout's two-line clamp does not apply — so the ask can be read in the list (#1177). The dialog body scrolls. Hover still carries the full title. An approval the inbox lists and the group chat's card can answer (`answerable`) also has **Reject** / **Allow once**, which call `actions.answerApproval`, the same answer as that card (no second approval path); the card leaves and a status line says what was done; an error stays on the card. No native confirm dialog. A held Bot message and an approval DSH's card answers in the Bot's own chat have **Open** only: Bot-sent messages are approved in their chat. Ask_user rows and a task waiting on input are answered in the inbox, as the previous bullet says. Every action is a button, so the dialog is keyboard reachable; the close button takes focus and Escape closes. - **Done is the acceptance surface (#401).** A `result-ready` task is a *Needs you* card with the result summary as its reason, its evidence as up to five links (`task.result.links`, rendered under the title) and the producing Bot plus its model in the meta line. The card answers the result in place: **Accept** calls `actions.acceptTaskResult` (`task-accept-result`) and the card leaves; **Send back** opens a one-line box (`What should change`) and **Send it back** calls `actions.sendBackTask` (`task-send-back`) with that note, so the same task returns to the Bot in *In progress* and shows *Sent back: …* (`task.followUp.note`). A note is required — an empty box sends nothing — and the box survives a roster or clock poll, like a short answer (#1140). Typing an approval in the chat is not an acceptance: this button is. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 2f1b2ddc0..5e4cd6a63 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -4387,7 +4387,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2488,2292 +2488,4419 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,2292 +2488,4420 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -9432,6 +9432,7 @@ diff --git a/client.js b/client.js + for (const [key, entry] of lastEnded) { + const bot = ownBot(roster, entry.botId); + if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue; ++ if (bot.hidden === true && entry.roomId === void 0) continue; + const chat = entry.roomId ?? bot.id; + const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : ""; + needs.push({ @@ -14273,31 +14274,29 @@ diff --git a/client.js b/client.js if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -@@ -7689,52 +7689,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; +@@ -7685,475 +7685,1171 @@ function MemoryDialog({ bot, actions, leaving = false }) { + } + inputRef.current?.focus(); + }; +- const count = view?.entries.length ?? 0; +- 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_react17.createElement)( ++ const count = view?.entries.length ?? 0; ++ 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_react25.createElement)( - "div", - { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, -- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, + (0, import_react25.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react25.createElement)( - "div", - { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, + (0, import_react25.createElement)( - "div", - { className: "bt-mem-head" }, -- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, -- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), -- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), -- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), -- (0, import_react17.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_react17.createElement)(DotsIcon)), -- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), -- menuShown ? (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem-head" }, + page === "entries" ? (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react25.createElement)(ChevronLeftIcon)) : null, + (0, import_react25.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), + (0, import_react25.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), @@ -14305,67 +14304,55 @@ diff --git a/client.js b/client.js + (0, import_react25.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_react25.createElement)(DotsIcon)), + (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react25.createElement)(CloseIcon)), + menuShown ? (0, import_react25.createElement)( - "div", - { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, -- (0, import_react17.createElement)( ++ "div", ++ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, + (0, import_react25.createElement)( - "button", - { type: "button", role: "menuitem", onClick: openEntries }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), -- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) ++ "button", ++ { type: "button", role: "menuitem", onClick: openEntries }, + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("All entries")), + (0, import_react25.createElement)("span", { className: "bt-menu-hint" }, String(count)) - ), -- (0, import_react17.createElement)( ++ ), + (0, import_react25.createElement)( - "button", - { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) ++ "button", ++ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) - ) - ) : null - ), -- (0, import_react17.createElement)( ++ ) ++ ) : null ++ ), + (0, import_react25.createElement)( - "div", - { className: "bt-mem-body" }, -- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, -- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) ++ "div", ++ { className: "bt-mem-body" }, + error ? (0, import_react25.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, + view === null ? error ? null : (0, import_react25.createElement)(Skeleton) : page === "entries" ? (0, import_react25.createElement)(Entries, { bot, view, remove }) : (0, import_react25.createElement)(Summary, { bot, view, regenerate }) - ), -- (0, import_react17.createElement)( ++ ), + (0, import_react25.createElement)( - "div", - { className: "bt-mem-foot" }, -- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, -- (0, import_react17.createElement)( ++ "div", ++ { className: "bt-mem-foot" }, + answer ? (0, import_react25.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, + (0, import_react25.createElement)( - "form", - { className: "bt-mem-ask", onSubmit: ask }, -- (0, import_react17.createElement)("input", { ++ "form", ++ { className: "bt-mem-ask", onSubmit: ask }, + (0, import_react25.createElement)("input", { - ref: inputRef, - className: "bt-mem-input", - value: text, -@@ -7744,10 +7744,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_react17.createElement)( ++ ref: inputRef, ++ className: "bt-mem-input", ++ value: text, ++ maxLength: 2e3, ++ disabled: view === null, ++ placeholder: t("Ask or update"), ++ "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), ++ onChange: (event) => setText(event.target.value) ++ }), + (0, import_react25.createElement)( - "button", - { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, -- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) ++ "button", ++ { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, + busy ? (0, import_react25.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react25.createElement)(ArrowUpIcon) - ) - ) - ) -@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { - ); - } - ++ ) ++ ) ++ ) ++ ) ++ ); ++} ++ +// src/client/standing-approvals.js +var import_react26 = require("react"); +var standingOf = (target, roster) => { @@ -14398,313 +14385,365 @@ diff --git a/client.js b/client.js + ); +} + - // src/client/bot-settings.js --var import_react18 = require("react"); ++// src/client/bot-settings.js +var import_react27 = 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")); -@@ -7780,8 +7780,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_react18.useState)(null); -- const reverting = (0, import_react18.useRef)(false); ++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")); ++ if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB")); ++ const bitmap = await createImageBitmap(file); ++ const side = 160; ++ const canvas = document.createElement("canvas"); ++ canvas.width = side; ++ canvas.height = side; ++ const context = canvas.getContext("2d"); ++ context.imageSmoothingQuality = "high"; ++ const crop = Math.min(bitmap.width, bitmap.height); ++ context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side); ++ bitmap.close?.(); ++ for (const quality of [0.9, 0.75, 0.6]) { ++ const url = canvas.toDataURL("image/webp", quality); ++ if (url.startsWith("data:image/webp") && url.length < 15e4) return url; ++ } ++ const png = canvas.toDataURL("image/png"); ++ if (png.length < 15e4) return png; ++ 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_react27.useState)(null); + const reverting = (0, import_react27.useRef)(false); - const commit = () => { - const next = draft?.trim(); - setDraft(null); -@@ -7792,7 +7792,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_react18.createElement)(multiline ? "textarea" : "input", { ++ const commit = () => { ++ const next = draft?.trim(); ++ setDraft(null); ++ if (reverting.current) { ++ reverting.current = false; ++ return; ++ } ++ if (next === void 0 || next === (value ?? "") || required && next === "") return; ++ void onSave(next); ++ }; + return (0, import_react27.createElement)(multiline ? "textarea" : "input", { - className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", - value: draft ?? value ?? "", - placeholder, -@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu - }); - } - function Row({ label, hint, stack = false, children }) { -- return (0, import_react18.createElement)( ++ className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", ++ value: draft ?? value ?? "", ++ placeholder, ++ maxLength, ++ required, ++ "aria-label": label, ++ spellCheck: false, ++ onChange: (event) => setDraft(event.target.value), ++ onBlur: commit, ++ onKeyDown: (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); ++ reverting.current = true; ++ event.currentTarget.blur(); ++ } ++ if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur(); ++ } ++ }); ++} ++function Row({ label, hint, stack = false, children }) { + return (0, import_react27.createElement)( - "label", - { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), ++ "label", ++ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, hint) : null), - children - ); - } - function Card({ title, children }) { -- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, children)); ++ children ++ ); ++} ++function Card({ title, children }) { + return (0, import_react27.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react27.createElement)("h3", null, title) : null, (0, import_react27.createElement)("div", { className: "bt-set-card" }, children)); - } - function Switch({ on, label, disabled, onChange }) { -- return (0, import_react18.createElement)( ++} ++function Switch({ on, label, disabled, onChange }) { + return (0, import_react27.createElement)( - "button", - { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, -- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) ++ "button", ++ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, + (0, import_react27.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) - ); - } --function SwitchRow({ label, hint, on, disabled, onChange }) { -- return (0, import_react18.createElement)( ++ ); ++} +function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) { + return (0, import_react27.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), -- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react27.createElement)(Switch, { on, label, disabled, onChange }) - ); - } - function modelHint(bot, roster) { -@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { - return t("Applies from the next message."); - } - function ShapeRing({ shape, size }) { -- const maskId = (0, import_react18.useRef)(""); ++ ); ++} ++function modelHint(bot, roster) { ++ const shown = modelOf(bot, roster); ++ if (shown === void 0) return t("Pick the model this Bot runs on."); ++ if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one."); ++ if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it."); ++ return t("Applies from the next message."); ++} ++function ShapeRing({ shape, size }) { + const maskId = (0, import_react27.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_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); ++ 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_react27.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_react18.createElement)( -- import_react18.Fragment, ++} ++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_react27.createElement)( + import_react27.Fragment, - null, -- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.createElement)("button", { ++ null, + (0, import_react27.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react27.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react27.createElement)("button", { - key: id, - type: "button", - className: "bt-shape", -@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { - title: t(shapeOf(id).label), - "aria-pressed": !hasPhoto && look.shape === id, - onClick: () => onShape(id) -- }, (0, import_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), -- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.createElement)("button", { ++ key: id, ++ type: "button", ++ className: "bt-shape", ++ "aria-label": t(shapeOf(id).label), ++ title: t(shapeOf(id).label), ++ "aria-pressed": !hasPhoto && look.shape === id, ++ onClick: () => onShape(id) + }, (0, import_react27.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react27.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), + (0, import_react27.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react27.createElement)("button", { - key: color, - type: "button", - className: "bt-color", -@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { - title: t(capitalize(color)), - "aria-pressed": look.color === color, - onClick: () => onColor(color) -- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) ++ key: color, ++ type: "button", ++ className: "bt-color", ++ "aria-label": t(capitalize(color)), ++ title: t(capitalize(color)), ++ "aria-pressed": look.color === color, ++ onClick: () => onColor(color) + }, (0, import_react27.createElement)("span", { style: { background: paintCss(color) } })))) - ); - } - function AvatarEditor({ bot, actions, run, onClose }) { - const look = lookOf(bot); -- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); -- const [over, setOver] = (0, import_react18.useState)(false); -- const ref = (0, import_react18.useRef)(null); -- const fileRef = (0, import_react18.useRef)(null); ++ ); ++} ++function AvatarEditor({ bot, actions, run, onClose }) { ++ const look = lookOf(bot); + const [tab, setTab] = (0, import_react27.useState)(look.image ? "upload" : "bot"); + const [over, setOver] = (0, import_react27.useState)(false); + const ref = (0, import_react27.useRef)(null); + const fileRef = (0, import_react27.useRef)(null); - const upload = (file) => { - if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); - }; -- (0, import_react18.useEffect)(() => { ++ const upload = (file) => { ++ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); ++ }; + (0, import_react27.useEffect)(() => { - const onDown = (event) => { - if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; - onClose(); -@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { - window.removeEventListener("keydown", onKey, true); - }; - }, [onClose]); -- (0, import_react18.useEffect)(() => { ++ const onDown = (event) => { ++ if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; ++ onClose(); ++ }; ++ const onKey = (event) => { ++ if (event.key === "Escape") { ++ event.stopPropagation(); ++ onClose(); ++ } ++ }; ++ window.addEventListener("mousedown", onDown); ++ window.addEventListener("keydown", onKey, true); ++ return () => { ++ window.removeEventListener("mousedown", onDown); ++ window.removeEventListener("keydown", onKey, true); ++ }; ++ }, [onClose]); + (0, import_react27.useEffect)(() => { - if (tab !== "upload") return void 0; - const onPaste = (event) => { - const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); -@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { - }, [tab, bot]); - const hasPhoto = Boolean(look.image); - const glide = useGlide(tab); -- return (0, import_react18.createElement)( ++ if (tab !== "upload") return void 0; ++ const onPaste = (event) => { ++ const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); ++ if (file) { ++ event.preventDefault(); ++ upload(file); ++ } ++ }; ++ window.addEventListener("paste", onPaste); ++ return () => window.removeEventListener("paste", onPaste); ++ }, [tab, bot]); ++ const hasPhoto = Boolean(look.image); ++ const glide = useGlide(tab); + return (0, import_react27.createElement)( - "div", - { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, -- (0, import_react18.createElement)( ++ "div", ++ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, + (0, import_react27.createElement)( - "div", - { className: "bt-avedit-top" }, -- (0, import_react18.createElement)( ++ "div", ++ { className: "bt-avedit-top" }, + (0, import_react27.createElement)( - "div", - { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, -- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) ++ "div", ++ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, + (0, import_react27.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react27.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) - ), -- (0, import_react18.createElement)("button", { ++ ), + (0, import_react27.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_react18.createElement)(CharacterGrid, { ++ 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_react27.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_react18.createElement)( ++ look, ++ hasPhoto, ++ onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), ++ onColor: (color) => run(actions.updateBot(bot.id, { color })) ++ }) : null, + tab === "upload" ? (0, import_react27.createElement)( - "div", - { - className: "bt-dropzone", -@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { - upload(event.dataTransfer.files?.[0]); - } - }, -- hasPhoto ? (0, import_react18.createElement)("img", { src: look.image, alt: "" }) : null, -- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), -- (0, import_react18.createElement)( ++ "div", ++ { ++ className: "bt-dropzone", ++ "data-over": over || void 0, ++ onDragOver: (event) => { ++ event.preventDefault(); ++ setOver(true); ++ }, ++ onDragLeave: () => setOver(false), ++ onDrop: (event) => { ++ event.preventDefault(); ++ setOver(false); ++ upload(event.dataTransfer.files?.[0]); ++ } ++ }, + hasPhoto ? (0, import_react27.createElement)("img", { src: look.image, alt: "" }) : null, + (0, import_react27.createElement)("div", null, t("Drag, drop, or paste an image")), + (0, import_react27.createElement)( - "div", - { className: "bt-actions", style: { justifyContent: "center" } }, -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), -- hasPhoto ? (0, import_react18.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 ++ "div", ++ { className: "bt-actions", style: { justifyContent: "center" } }, + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), + hasPhoto ? (0, import_react27.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_react18.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { ++ ), + (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); -@@ -7982,26 +7982,27 @@ 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_react18.createElement)( ++ const file = event.target.files?.[0]; ++ event.target.value = ""; ++ upload(file); ++ } }) ++ ) : null ++ ); ++} ++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_react27.createElement)( - Card, - { title: t("Memory") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Memory") }, + (0, import_react27.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_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), -- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) ++ "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_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label)), + (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon)) - ) - ); - } - function BotSettingsForm({ bot, roster, actions, run }) { -- const [editing, setEditing] = (0, import_react18.useState)(false); -- const [confirming, setConfirming] = (0, import_react18.useState)(false); -- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); -- (0, import_react18.useEffect)(() => { ++ ) ++ ); ++} ++function BotSettingsForm({ bot, roster, actions, run }) { + const [editing, setEditing] = (0, import_react27.useState)(false); + const [confirming, setConfirming] = (0, import_react27.useState)(false); + const closeEditor = (0, import_react27.useCallback)(() => setEditing(false), []); + (0, import_react27.useEffect)(() => { - if (!confirming) return void 0; - const timer = setTimeout(() => setConfirming(false), 4e3); - return () => clearTimeout(timer); - }, [confirming]); ++ if (!confirming) return void 0; ++ const timer = setTimeout(() => setConfirming(false), 4e3); ++ return () => clearTimeout(timer); ++ }, [confirming]); + if (bot.remote) return null; - const isMainBot = isMainOf(roster, bot.id); - const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; - const isTile = roster.mainBotId === bot.id; -@@ -8011,149 +8011,812 @@ function BotSettingsForm({ bot, roster, actions, run }) { - if (confirming) void run(actions.deleteBot(bot.id)); - else setConfirming(true); - }; -- (0, import_react18.useEffect)(() => { ++ const isMainBot = isMainOf(roster, bot.id); ++ const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; ++ const isTile = roster.mainBotId === bot.id; ++ const save = (patch) => run(actions.updateBot(bot.id, patch)); ++ const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); ++ const remove = () => { ++ if (confirming) void run(actions.deleteBot(bot.id)); ++ else setConfirming(true); ++ }; + (0, import_react27.useEffect)(() => { - void actions.refreshModels?.(); - }, [bot.id]); -- return (0, import_react18.createElement)( ++ void actions.refreshModels?.(); ++ }, [bot.id]); + return (0, import_react27.createElement)( "div", - { className: "bt-bs" }, -- (0, import_react18.createElement)( +- { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, +- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), +- (0, import_react17.createElement)( ++ { className: "bt-bs" }, + (0, import_react27.createElement)( "div", - { className: "bt-bs-hero" }, -- (0, import_react18.createElement)( +- { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, +- (0, import_react17.createElement)( ++ { className: "bt-bs-hero" }, + (0, import_react27.createElement)( "div", - { className: "bt-avatar-anchor" }, -- (0, import_react18.createElement)( +- { className: "bt-mem-head" }, +- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, +- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), +- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), +- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), +- (0, import_react17.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_react17.createElement)(DotsIcon)), +- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), +- menuShown ? (0, import_react17.createElement)( +- "div", +- { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, +- (0, import_react17.createElement)( +- "button", +- { type: "button", role: "menuitem", onClick: openEntries }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), +- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) +- ), +- (0, import_react17.createElement)( +- "button", +- { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) +- ) +- ) : null ++ { className: "bt-avatar-anchor" }, + (0, import_react27.createElement)( - "button", - { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, -- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), -- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) ++ "button", ++ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, + (0, import_react27.createElement)(BotMark, { bot, size: 64, live: true }), + (0, import_react27.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react27.createElement)(PencilIcon)) - ), -- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ++ ), + editing ? (0, import_react27.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ), -- (0, import_react18.createElement)( +- (0, import_react17.createElement)( + (0, import_react27.createElement)( "div", - { className: "bt-bs-who" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), -- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) +- { className: "bt-mem-body" }, +- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, +- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) ++ { className: "bt-bs-who" }, + (0, import_react27.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react27.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + (0, import_react27.createElement)( - Card, - { title: t("Profile") }, -- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), -- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) ++ Card, ++ { title: t("Profile") }, + (0, import_react27.createElement)(Row, { label: t("Name") }, (0, import_react27.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), + (0, import_react27.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react27.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) - ), -- (0, import_react18.createElement)( ++ ), + (0, import_react27.createElement)( - Card, - { title: t("Model") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Model") }, + (0, import_react27.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), + (0, import_react27.createElement)(ModelMenu, { bot, roster, actions, run }) -+ ), + ), +- (0, import_react17.createElement)( + bot.model ? (0, import_react27.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null + ), + (0, import_react27.createElement)( + Card, + { title: t("Working folder") }, + (0, import_react27.createElement)( -+ "div", + "div", +- { className: "bt-mem-foot" }, +- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, +- (0, import_react17.createElement)( +- "form", +- { className: "bt-mem-ask", onSubmit: ask }, +- (0, import_react17.createElement)("input", { +- ref: inputRef, +- className: "bt-mem-input", +- value: text, +- maxLength: 2e3, +- disabled: view === null, +- placeholder: t("Ask or update"), +- "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), +- onChange: (event) => setText(event.target.value) +- }), +- (0, import_react17.createElement)( +- "button", +- { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, +- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) +- ) +- ) + { className: "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react27.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) @@ -14730,10 +14769,8 @@ diff --git a/client.js b/client.js + Card, + { title: t("Main Bot") }, + onlyMain ? (0, import_react27.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), -- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react27.createElement)( + "span", + { className: "bt-set-copy" }, @@ -14768,10 +14805,10 @@ diff --git a/client.js b/client.js + (0, import_react27.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_react27.createElement)("span", { className: "bt-chevron" }, (0, import_react27.createElement)(ChevronRightIcon)) -+ ) -+ ) -+ ); -+} + ) + ) + ); + } +var BOT_SETTINGS_CSS = ` +.bt-bs{display:flex;flex-direction:column;gap:18px} +.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px} @@ -14821,7 +14858,26 @@ diff --git a/client.js b/client.js + .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left} +} +`; -+ + +-// src/client/bot-settings.js +-var import_react18 = 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")); +- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB")); +- const bitmap = await createImageBitmap(file); +- const side = 160; +- const canvas = document.createElement("canvas"); +- canvas.width = side; +- canvas.height = side; +- const context = canvas.getContext("2d"); +- context.imageSmoothingQuality = "high"; +- const crop = Math.min(bitmap.width, bitmap.height); +- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side); +- bitmap.close?.(); +- for (const quality of [0.9, 0.75, 0.6]) { +- const url = canvas.toDataURL("image/webp", quality); +- if (url.startsWith("data:image/webp") && url.length < 15e4) return url; +// src/client/group-settings.js +var import_react28 = require("react"); +var ROOM_MODES = [ @@ -14856,7 +14912,42 @@ diff --git a/client.js b/client.js + const bot = faceSeatBot(seat, roster); + if (!bot) continue; + marks.push((0, import_react28.createElement)(BotMark, { key: seat.key, bot, size, live: false })); -+ } + } +- const png = canvas.toDataURL("image/png"); +- if (png.length < 15e4) return png; +- 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_react18.useState)(null); +- const reverting = (0, import_react18.useRef)(false); +- const commit = () => { +- const next = draft?.trim(); +- setDraft(null); +- if (reverting.current) { +- reverting.current = false; +- return; +- } +- if (next === void 0 || next === (value ?? "") || required && next === "") return; +- void onSave(next); +- }; +- return (0, import_react18.createElement)(multiline ? "textarea" : "input", { +- className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", +- value: draft ?? value ?? "", +- placeholder, +- maxLength, +- required, +- "aria-label": label, +- spellCheck: false, +- onChange: (event) => setDraft(event.target.value), +- onBlur: commit, +- onKeyDown: (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- reverting.current = true; +- event.currentTarget.blur(); +- } +- if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur(); +- } + if (marks.length === 0) return [(0, import_react28.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + return marks; +} @@ -14874,7 +14965,24 @@ diff --git a/client.js b/client.js + const name = seat.local && onOpenBot ? (0, import_react28.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_react28.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_react28.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; + return index === 0 ? marked : [(0, import_react28.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; -+ }); + }); +-} +-function Row({ label, hint, stack = false, children }) { +- return (0, import_react18.createElement)( +- "label", +- { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), +- children +- ); +-} +-function Card({ title, children }) { +- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, children)); +-} +-function Switch({ on, label, disabled, onChange }) { +- return (0, import_react18.createElement)( +- "button", +- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, +- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) + return (0, import_react28.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, @@ -14889,8 +14997,10 @@ diff --git a/client.js b/client.js + (0, import_react28.createElement)("span", { className: "bt-face-bots" }, bots) + ) + ) -+ ); -+} + ); + } +-function SwitchRow({ label, hint, on, disabled, onChange }) { +- return (0, import_react18.createElement)( +function FaceLead({ room: room2, roster, pick: pick2 }) { + const lead = room2.face?.lead; + const [open, setOpen] = (0, import_react28.useState)(false); @@ -14913,7 +15023,10 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), +- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) + { className: "bt-face-lead", "data-flybot": "face-lead" }, + (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Room lead")), + (0, import_react28.createElement)( @@ -14936,20 +15049,69 @@ diff --git a/client.js b/client.js + open && canPick && lead.picked ? (0, import_react28.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_react28.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + error ? (0, import_react28.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null -+ ); -+} + ); + } +-function modelHint(bot, roster) { +- const shown = modelOf(bot, roster); +- if (shown === void 0) return t("Pick the model this Bot runs on."); +- if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one."); +- if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it."); +- return t("Applies from the next message."); +-} +-function ShapeRing({ shape, size }) { +- const maskId = (0, import_react18.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_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); +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 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_react18.createElement)( +- import_react18.Fragment, +- null, +- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.createElement)("button", { +- key: id, +- type: "button", +- className: "bt-shape", +- "aria-label": t(shapeOf(id).label), +- title: t(shapeOf(id).label), +- "aria-pressed": !hasPhoto && look.shape === id, +- onClick: () => onShape(id) +- }, (0, import_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), +- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.createElement)("button", { +- key: color, +- type: "button", +- className: "bt-color", +- "aria-label": t(capitalize(color)), +- title: t(capitalize(color)), +- "aria-pressed": look.color === color, +- onClick: () => onColor(color) +- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) +- ); +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 AvatarEditor({ bot, actions, run, onClose }) { +- const look = lookOf(bot); +- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); +- const [over, setOver] = (0, import_react18.useState)(false); +- const ref = (0, import_react18.useRef)(null); +- const fileRef = (0, import_react18.useRef)(null); +- const upload = (file) => { +- if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); +function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { + const [adding, setAdding] = (0, import_react28.useState)(false); + const [confirming, setConfirming] = (0, import_react28.useState)(false); @@ -14981,7 +15143,40 @@ diff --git a/client.js b/client.js + const openBot = (id) => { + actions.closeSettings(); + actions.openSession(id); -+ }; + }; +- (0, import_react18.useEffect)(() => { +- const onDown = (event) => { +- if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; +- onClose(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- onClose(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [onClose]); +- (0, import_react18.useEffect)(() => { +- if (tab !== "upload") return void 0; +- const onPaste = (event) => { +- const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); +- if (file) { +- event.preventDefault(); +- upload(file); +- } +- }; +- window.addEventListener("paste", onPaste); +- return () => window.removeEventListener("paste", onPaste); +- }, [tab, bot]); +- const hasPhoto = Boolean(look.image); +- const glide = useGlide(tab); +- return (0, import_react18.createElement)( + const remove = () => { + if (!confirming) { + setConfirming(true); @@ -14992,14 +15187,21 @@ diff --git a/client.js b/client.js + }); + }; + return (0, import_react28.createElement)( -+ "div", + "div", +- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, +- (0, import_react18.createElement)( + { className: "bt-bs" }, + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-avedit-top" }, +- (0, import_react18.createElement)( + { className: "bt-bs-hero" }, + (0, import_react28.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), + (0, import_react28.createElement)( -+ "div", + "div", +- { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, +- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) + { className: "bt-bs-who" }, + (0, import_react28.createElement)("span", { className: "bt-bs-name" }, room2.name), + (0, import_react28.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) @@ -15012,18 +15214,72 @@ diff --git a/client.js b/client.js + Row, + { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, + (0, import_react28.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) -+ ), + ), +- (0, import_react18.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")) + (0, import_react28.createElement)( + Row, + { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, + (0, import_react28.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 }) }) + ) -+ ), + ), +- tab === "bot" ? (0, import_react18.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_react18.createElement)( +- "div", +- { +- className: "bt-dropzone", +- "data-over": over || void 0, +- onDragOver: (event) => { +- event.preventDefault(); +- setOver(true); +- }, +- onDragLeave: () => setOver(false), +- onDrop: (event) => { +- event.preventDefault(); +- setOver(false); +- upload(event.dataTransfer.files?.[0]); +- } +- }, +- hasPhoto ? (0, import_react18.createElement)("img", { src: look.image, alt: "" }) : null, +- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), +- (0, import_react18.createElement)( + (0, import_react28.createElement)( + Card, + { title: t("Who answers") }, + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-actions", style: { justifyContent: "center" } }, +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), +- hasPhoto ? (0, import_react18.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_react18.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); +- } }) +- ) : null +- ); +-} +-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_react18.createElement)( +- Card, +- { title: t("Memory") }, +- (0, import_react18.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_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), +- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react28.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react28.createElement)("button", { + key: id, @@ -15036,54 +15292,31 @@ diff --git a/client.js b/client.js + } + }, t(label)))), + (0, import_react28.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + (0, import_react28.createElement)( - Card, - { title: t("Working folder") }, -- (0, import_react18.createElement)( ++ Card, ++ { title: t("Working folder") }, + (0, import_react28.createElement)( - "div", - { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) ++ "div", ++ { className: "bt-set-row bt-bs-row" }, + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react28.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) - ) - ), -- (0, import_react18.createElement)( ++ ) ++ ), + face ? (0, import_react28.createElement)( - Card, -- { title: t("Instructions") }, -- (0, import_react18.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_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) -- ) -- ), -- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), -- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), -- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), -- (0, import_react18.createElement)( ++ Card, + { title: t("Members · {count}", { count: people2.length }) }, + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + ) : null, + face ? null : (0, import_react28.createElement)( - Card, -- { title: t("Main Bot") }, -- onlyMain ? (0, import_react18.createElement)( ++ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react28.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)( ++ "div", + { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, + (0, import_react28.createElement)( - "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.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.")) ++ "span", + { className: "bt-gs-who" }, + (0, import_react28.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), + (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Human")), @@ -15123,9 +15356,7 @@ diff --git a/client.js b/client.js + // #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_react28.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) - ), -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ++ ), + // 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_react28.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 }] }) }, "×") + )), @@ -15170,29 +15401,19 @@ diff --git a/client.js b/client.js + // #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_react28.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }) - ), -- // The Main Bot tile is always first and always shown, like in the context menu. -- isTile ? null : (0, import_react18.createElement)( ++ ), + (0, import_react28.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react28.createElement)( - Card, - { title: t("Sidebar") }, -- (0, import_react18.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_react18.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 })) }) ++ Card, ++ { title: t("Sidebar") }, + (0, import_react28.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_react28.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_react18.createElement)( ++ ), + (0, import_react28.createElement)( - Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( ++ Card, + { title: t("More") }, + (0, import_react28.createElement)( - "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ "button", + { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, + (0, import_react28.createElement)( + "span", @@ -15202,9 +15423,26 @@ diff --git a/client.js b/client.js + ), + (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) + ) -+ ) -+ ); -+} + ) + ); + } +-function BotSettingsForm({ bot, roster, actions, run }) { +- const [editing, setEditing] = (0, import_react18.useState)(false); +- const [confirming, setConfirming] = (0, import_react18.useState)(false); +- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); +- (0, import_react18.useEffect)(() => { +- if (!confirming) return void 0; +- const timer = setTimeout(() => setConfirming(false), 4e3); +- return () => clearTimeout(timer); +- }, [confirming]); +- const isMainBot = isMainOf(roster, bot.id); +- const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; +- const isTile = roster.mainBotId === bot.id; +- const save = (patch) => run(actions.updateBot(bot.id, patch)); +- const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id))); +- const remove = () => { +- if (confirming) void run(actions.deleteBot(bot.id)); +- else setConfirming(true); +function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { + const [admin, setAdmin] = (0, import_react28.useState)(null); + const [members, setMembers] = (0, import_react28.useState)([]); @@ -15214,7 +15452,11 @@ diff --git a/client.js b/client.js + const pickAdmin = (id) => { + setAdmin(id); + setMembers((list) => list.filter((item) => item !== id)); -+ }; + }; +- (0, import_react18.useEffect)(() => { +- void actions.refreshModels?.(); +- }, [bot.id]); +- return (0, import_react18.createElement)( + const create = async () => { + if (busy || admin === null || members.length === 0) return; + setBusy(true); @@ -15234,34 +15476,90 @@ diff --git a/client.js b/client.js + onClick + }, (0, import_react28.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react28.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react28.createElement)(AdminStar) : null); + return (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-bs" }, +- (0, import_react18.createElement)( + { className: "bt-set-card bt-ng" }, + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-bs-hero" }, +- (0, import_react18.createElement)( +- "div", +- { className: "bt-avatar-anchor" }, +- (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, +- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), +- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) +- ), +- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null +- ), +- (0, import_react18.createElement)( +- "div", +- { className: "bt-bs-who" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), +- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) +- ) +- ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Profile") }, +- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), +- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) +- ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Model") }, +- (0, import_react18.createElement)( + { className: "bt-set-row bt-bs-row bt-bs-stack" }, + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react28.createElement)(AdminStar), t("Choose the group admin"))), + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), +- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) + { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, + roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Working folder") }, +- (0, import_react18.createElement)( + admin === null ? null : (0, import_react28.createElement)( + "div", + { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), + (0, import_react28.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) + { 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")) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)( +- Card, +- { title: t("Instructions") }, +- (0, import_react18.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_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) +- ) + admin === null || members.length === 0 ? null : (0, import_react28.createElement)( + "label", + { className: "bt-set-row bt-bs-row bt-ng-step" }, + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Name"))), + (0, import_react28.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) -+ ), + ), +- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), +- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), +- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), +- (0, import_react18.createElement)( +- Card, +- { title: t("Main Bot") }, +- onlyMain ? (0, import_react18.createElement)( + (0, import_react28.createElement)( + "div", + { className: "bt-set-row bt-task-foot" }, @@ -15419,7 +15717,32 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, + (0, import_react29.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.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_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), 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_react18.createElement)( +- Card, +- { title: t("Sidebar") }, +- (0, import_react18.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_react18.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_react18.createElement)( +- Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + { className: "bt-palette-head" }, + (0, import_react29.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react29.createElement)(SearchIcon)), + (0, import_react29.createElement)("input", { diff --git a/patches/ds-bot/files/src/client/inbox.js b/patches/ds-bot/files/src/client/inbox.js index beafd6545..5e75347a6 100644 --- a/patches/ds-bot/files/src/client/inbox.js +++ b/patches/ds-bot/files/src/client/inbox.js @@ -229,6 +229,9 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date for (const [key, entry] of lastEnded) { const bot = ownBot(roster, entry.botId) if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue + // A hidden Bot has no sidebar row, so this card's only action (Open) could not show the chat + // it names (#1566). Its failure in a group still lists: the group's own row opens there. + if (bot.hidden === true && entry.roomId === undefined) continue const chat = entry.roomId ?? bot.id const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : '' needs.push({ diff --git a/patches/ds-bot/files/test/inbox.test.js b/patches/ds-bot/files/test/inbox.test.js index ecb997482..efd702164 100644 --- a/patches/ds-bot/files/test/inbox.test.js +++ b/patches/ds-bot/files/test/inbox.test.js @@ -311,6 +311,31 @@ test('a failure no longer needs me once the Bot works or waits there again', () assert.equal(freed.needs.find(card => card.id === 'turn:e3').place, 'in Ops') }) +// #1566: a hidden Bot has no sidebar row, so the failed-turn card's only action (Open) could +// never reveal the chat it names. Its failure in its own chat is left out; one in a group still +// lists, because the group's row opens it. Progress keeps counting a hidden Bot's running turn. +const hiddenRoster = { ...roster, bots: roster.bots.map(bot => (bot.id === 'b2' ? { ...bot, hidden: true } : bot)) } +hiddenRoster.byId = Object.fromEntries(hiddenRoster.bots.map(bot => [bot.id, bot])) + +test("a failed turn in a hidden Bot's own chat is not a Needs you card (#1566)", () => { + const hidden = inboxModel({ roster: hiddenRoster, work: endedWork, tasks, sessions: endedSessions, now: NOW }) + assert.ok(!ids(hidden.needs).includes('turn:e2')) + // The count badge drops it with the card, and it is not reclassified into Done. + assert.equal(needsCount(hiddenRoster, endedWork, tasks, NOW), model.needs.length) + assert.ok(!ids(hidden.done).includes('chat:b2')) + // The same snapshot with a visible Bot still lists it: the card itself is not the defect. + assert.ok(ids(endedModel.needs).includes('turn:e2')) + // A hidden Bot failing in a group still needs me: that chat is opened from the group's row. + const inGroup = { ...endedWork, ended: [...endedWork.ended, { id: 'e9', botId: 'b2', roomId: 'r1', at: NOW - 30_000, failed: 'quota' }] } + const groupModel = inboxModel({ roster: hiddenRoster, work: inGroup, tasks, sessions: endedSessions, now: NOW }) + const groupCard = groupModel.needs.find(card => card.id === 'turn:e9') + assert.ok(groupCard, 'a hidden Bot failing in a group is still listed') + assert.equal(groupCard.chat, 'r1') + // In progress keeps counting a hidden Bot's running turn; this fix only reads Needs you. + const running = inboxModel({ roster: hiddenRoster, work: { ...endedWork, bots: { ...endedWork.bots, b2: { since: NOW - 1000, tool: 'pwsh' } } }, tasks, sessions: endedSessions, now: NOW }) + assert.ok(ids(running.progress).includes('run:b2|')) +}) + test('each card has a one-line latest activity that never repeats its title or reason', () => { const byId = Object.fromEntries([...endedModel.needs, ...endedModel.progress, ...endedModel.done].map(card => [card.id, card])) // An approval and a running turn in Ops show that chat's last line.